Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add mobile gestures #1647

Closed
wants to merge 11 commits into from
3 changes: 2 additions & 1 deletion src/app/components/image-viewer/ImageViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export type ImageViewerProps = {
export const ImageViewer = as<'div', ImageViewerProps>(
({ className, alt, src, requestClose, ...props }, ref) => {
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
const { pan, cursor, onMouseDown, onTouchStart } = usePan(zoom > 1);

const handleDownload = () => {
FileSaver.saveAs(src, alt);
Expand Down Expand Up @@ -87,6 +87,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
src={src}
alt={alt}
onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
/>
</Box>
</Box>
Expand Down
64 changes: 63 additions & 1 deletion src/app/hooks/usePan.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,35 @@
import { MouseEventHandler, useEffect, useState } from 'react';
import { MouseEventHandler, TouchEventHandler, useEffect, useState } from 'react';

export type Pan = {
translateX: number;
translateY: number;
};

export type TouchPos = {
touchX: number;
touchY: number;
initX: number;
initY: number;
}

const INITIAL_PAN = {
translateX: 0,
translateY: 0,
};

const INITIAL_TOUCH_POS = {
touchX: 0,
touchY: 0,
initX: 0,
initY: 0
};

export const usePan = (active: boolean) => {
const [pan, setPan] = useState<Pan>(INITIAL_PAN);
const [cursor, setCursor] = useState<'grab' | 'grabbing' | 'initial'>(
active ? 'grab' : 'initial'
);
const [_touchPos, setTouchPos] = useState<TouchPos>(INITIAL_TOUCH_POS);

useEffect(() => {
setCursor(active ? 'grab' : 'initial');
Expand Down Expand Up @@ -50,6 +65,52 @@ export const usePan = (active: boolean) => {
document.addEventListener('mouseup', handleMouseUp);
};

// Touch handlers for usePan. Intentionally not handling 2 touches (may do in the future).
const handleTouchMove = (evt: TouchEvent) => {
evt.preventDefault();
evt.stopPropagation();
evt.stopImmediatePropagation();

let x = evt.touches[0].clientX;
let y = evt.touches[0].clientY;

setTouchPos(pos => {
pos.touchX = x;
pos.touchY = y;
setPan({ translateX: pos.touchX - pos.initX, translateY: pos.touchY - pos.initY });
return pos;
});
}

const handleTouchEnd = (evt: TouchEvent) => {
evt.preventDefault();
evt.stopPropagation();
evt.stopImmediatePropagation();
setCursor('grab');

document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
}

const handleTouchStart: TouchEventHandler<HTMLElement> = (evt) => {
if (!active || evt.touches.length != 1) return;
evt.preventDefault();
evt.stopPropagation();
setCursor('grabbing');

let x = evt.touches[0].clientX;
let y = evt.touches[0].clientY;
setTouchPos({
touchX: x,
touchY: y,
initX: x,
initY: y
});

document.addEventListener('touchmove', handleTouchMove);
document.addEventListener('touchend', handleTouchEnd);
}

useEffect(() => {
if (!active) setPan(INITIAL_PAN);
}, [active]);
Expand All @@ -58,5 +119,6 @@ export const usePan = (active: boolean) => {
pan,
cursor,
onMouseDown: handleMouseDown,
onTouchStart: handleTouchStart,
};
};
77 changes: 77 additions & 0 deletions src/app/hooks/useSwipeLeft.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { TouchEvent, useState } from "react";

export const useSwipeLeft = (handleReplyId: (replyId: string | null) => void) => {
// States used for swipe-left-reply. Used for animations and determining whether we should reply or not.
const [isTouchingSide, setTouchingSide] = useState(false);
const [sideMoved, setSideMoved] = useState(0);
const [sideMovedInit, setSideMovedInit] = useState(0);
const [swipingId, setSwipingId] = useState("");

// Touch handlers for the Message components. If touch starts at 90% of the right, it will trigger the swipe-left-reply.
let lastTouch = 0, sideVelocity = 0;
function onTouchStart(event: TouchEvent, replyId: string | undefined) {
if (event.touches.length != 1) return setTouchingSide(false);
if (
event.touches[0].clientX > window.innerWidth * 0.1 &&
!Array.from(document.elementsFromPoint(event.touches[0].clientX, event.touches[0].clientY)[0].classList).some(c => c.startsWith("ImageViewer")) // Disable gesture if ImageViewer is up. There's probably a better way I don't know
) {
setTouchingSide(true);
setSideMoved(event.touches[0].clientX);
setSideMovedInit(event.touches[0].clientX);
setSwipingId(replyId || "");
lastTouch = Date.now();
}
}
function onTouchEnd(event: TouchEvent) {
setTouchingSide(isTouchingSide => {
if (isTouchingSide) {
setSideMoved(sideMoved => {
if (sideMoved) {
setSideMovedInit(sideMovedInit => {
if ((sideMoved - sideMovedInit) < -(window.innerWidth * 0.2) || sideVelocity <= -(window.innerWidth * 0.05 / 250)) setSwipingId(swipingId => {
event.preventDefault();
setTimeout(() => handleReplyId(swipingId), 100);
return "";
});
return 0;
});
}
sideVelocity = lastTouch = 0;
return 0;
});
}
return false;
});
}
function onTouchMove(event: TouchEvent, replyId: string | undefined) {
if (event.touches.length != 1) return;
setTouchingSide(isTouchingSide => {
if (isTouchingSide) {
setSwipingId(swipingId => {
if (swipingId == replyId) {
event.preventDefault();
if (event.changedTouches.length != 1) setSideMoved(0);
else setSideMoved(sideMoved => {
const newSideMoved = event.changedTouches[0].clientX;
sideVelocity = (newSideMoved - sideMoved) / (Date.now() - lastTouch);
lastTouch = Date.now();
return newSideMoved;
});
}
return swipingId;
});
}
return isTouchingSide;
});
}

return {
isTouchingSide,
sideMoved,
sideMovedInit,
swipingId,
onTouchStart,
onTouchMove,
onTouchEnd
}
}
63 changes: 63 additions & 0 deletions src/app/hooks/useTouchMenu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { RefObject, TouchEvent, useState } from "react"
import { openNavigation } from "../../client/action/navigation";

export const useTouchMenu = (navWrapperRef: RefObject<HTMLDivElement>, classNameSided: string) => {
const [lastTouch, setLastTouch] = useState(0);
const [sideVelocity, setSideVelocity] = useState(0);
const [sideMoved, setSideMoved] = useState(0);
const [isTouchingSide, setTouchingSide] = useState(false);

// Touch handlers for window object. If the touch starts at 10% of the left of the screen, it will trigger the swipe-right-menu.
const onTouchStart = (event: TouchEvent) => {
if (!navWrapperRef.current?.classList.contains(classNameSided)) return;
if (event.touches.length != 1) return setTouchingSide(false);
if (event.touches[0].clientX < window.innerWidth * 0.1) {
setTouchingSide(true);
setLastTouch(Date.now());
}
}
const onTouchEnd = (event: TouchEvent) => {
if (!navWrapperRef.current?.classList.contains(classNameSided)) return;
setTouchingSide(isTouchingSide => {
if (isTouchingSide) {
setSideMoved(sideMoved => {
if (sideMoved) {
event.preventDefault();
if (sideMoved > window.innerWidth * 0.5 || sideVelocity >= (window.innerWidth * 0.1 / 250)) openNavigation();
}
setLastTouch(0);
setSideVelocity(0);
return 0;
});
}
return false;
});
}
const onTouchMove = (event: TouchEvent) => {
if (!navWrapperRef.current?.classList.contains(classNameSided)) return;
setTouchingSide(isTouchingSide => {
if (isTouchingSide) {
event.preventDefault();
if (event.changedTouches.length != 1) {
setSideMoved(0);
return false;
}
setSideMoved(sideMoved => {
const newSideMoved = event.changedTouches[0].clientX;
setSideVelocity((newSideMoved - sideMoved) / (Date.now() - lastTouch));
setLastTouch(Date.now());
return newSideMoved;
});
}
return isTouchingSide;
});
}

return {
isTouchingSide,
sideMoved,
onTouchStart,
onTouchMove,
onTouchEnd
}
}
34 changes: 26 additions & 8 deletions src/app/organisms/room/RoomTimeline.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import React, {
Dispatch,
MouseEvent,
MouseEventHandler,
RefObject,
SetStateAction,
TouchEvent,
useCallback,
useEffect,
useLayoutEffect,
Expand Down Expand Up @@ -101,7 +103,7 @@ import {
selectTab,
} from '../../../client/action/navigation';
import { useForceUpdate } from '../../hooks/useForceUpdate';
import { parseGeoUri, scaleYDimension } from '../../utils/common';
import { clamp, parseGeoUri, scaleYDimension } from '../../utils/common';
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
import { useRoomMsgContentRenderer } from '../../hooks/useRoomMsgContentRenderer';
import { IAudioContent, IImageContent, IVideoContent } from '../../../types/matrix/common';
Expand Down Expand Up @@ -141,6 +143,7 @@ import cons from '../../../client/state/cons';
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
import { EMOJI_PATTERN, HTTP_URL_PATTERN, VARIATION_SELECTOR_PATTERN } from '../../utils/regex';
import { UrlPreviewCard, UrlPreviewHolder } from './message/UrlPreviewCard';
import { useSwipeLeft } from '../../hooks/useSwipeLeft';

// Thumbs up emoji found to have Variation Selector 16 at the end
// so included variation selector pattern in regex
Expand Down Expand Up @@ -928,9 +931,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
[mx, room, editor]
);

const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(evt) => {
const replyId = evt.currentTarget.getAttribute('data-event-id');
// Replaces handleReplyClick. This takes an event id instead of the event to allow swipe-left-reply to directly cause a reply.
const handleReplyId = useCallback(
(replyId: string | null) => {
if (!replyId) {
console.warn('Button should have "data-event-id" attribute!');
return;
Expand Down Expand Up @@ -989,6 +992,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
[editor]
);

// swipe left hook
const { isTouchingSide, sideMoved, sideMovedInit, swipingId, onTouchStart, onTouchMove, onTouchEnd } = useSwipeLeft(handleReplyId);

const renderBody = (body: string, customBody?: string) => {
if (body === '') <MessageEmptyContent />;
if (customBody) {
Expand Down Expand Up @@ -1280,9 +1286,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick}
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReplyClick={(evt: MouseEvent) => handleReplyId(evt.currentTarget.getAttribute('data-event-id'))}
onReactionToggle={handleReactionToggle}
onEditId={handleEdit}
onTouchStart={(evt: TouchEvent) => onTouchStart(evt, mEvent.getId())}
onTouchMove={(evt: TouchEvent) => onTouchMove(evt, mEvent.getId())}
onTouchEnd={onTouchEnd}
style={{ transform: `translateX(${isTouchingSide && mEvent.getId() == swipingId ? clamp(sideMoved - sideMovedInit, -window.innerWidth, 0) : 0}px)` }}
reply={
replyEventId && (
<Reply
Expand Down Expand Up @@ -1338,9 +1348,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick}
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReplyClick={(evt: MouseEvent) => handleReplyId(evt.currentTarget.getAttribute('data-event-id'))}
onReactionToggle={handleReactionToggle}
onEditId={handleEdit}
onTouchStart={(evt: TouchEvent) => onTouchStart(evt, mEvent.getId())}
onTouchMove={(evt: TouchEvent) => onTouchMove(evt, mEvent.getId())}
onTouchEnd={onTouchEnd}
style={{ transform: `translateX(${isTouchingSide && mEvent.getId() == swipingId ? clamp(sideMoved - sideMovedInit, -window.innerWidth, 0) : 0}px)` }}
reply={
replyEventId && (
<Reply
Expand Down Expand Up @@ -1413,8 +1427,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick}
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReplyClick={(evt: MouseEvent) => handleReplyId(evt.currentTarget.getAttribute('data-event-id'))}
onReactionToggle={handleReactionToggle}
onTouchStart={(evt: TouchEvent) => onTouchStart(evt, mEvent.getId())}
onTouchMove={(evt: TouchEvent) => onTouchMove(evt, mEvent.getId())}
onTouchEnd={onTouchEnd}
style={{ transform: `translateX(${isTouchingSide && mEvent.getId() == swipingId ? clamp(sideMoved - sideMovedInit, -window.innerWidth, 0) : 0}px)` }}
reactions={
reactionRelations && (
<Reactions
Expand Down Expand Up @@ -1824,4 +1842,4 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
)}
</Box>
);
}
}
2 changes: 1 addition & 1 deletion src/app/organisms/room/RoomView.scss
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
width: 100%;
position: absolute;
top: 0;
z-index: 999;
z-index: 998;
box-shadow: none;

transition: transform 200ms var(--fluid-slide-down);
Expand Down
1 change: 1 addition & 0 deletions src/app/organisms/room/message/styles.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const ModalWide = style({

export const MessageBase = style({
position: 'relative',
transition: 'transform .25s ease',
});

export const MessageOptionsBase = style([
Expand Down
Loading
Loading