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,
};
};
94 changes: 86 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 @@ -928,9 +930,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 +991,70 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
[editor]
);

// States used for swipe-left-reply. Used for animations and determining whether we should reply or not.
const [isTouchingSide, setTouchingSide] = useState(false);
North-West-Wind marked this conversation as resolved.
Show resolved Hide resolved
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;
});
}

const renderBody = (body: string, customBody?: string) => {
if (body === '') <MessageEmptyContent />;
if (customBody) {
Expand Down Expand Up @@ -1280,9 +1346,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={isTouchingSide && mEvent.getId() == swipingId ? { transform: `translateX(${clamp(sideMoved - sideMovedInit, -window.innerWidth, 0)}px)` } : { transition: "all .25s ease" }}
North-West-Wind marked this conversation as resolved.
Show resolved Hide resolved
reply={
replyEventId && (
<Reply
Expand Down Expand Up @@ -1338,9 +1408,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={isTouchingSide && mEvent.getId() == swipingId ? { transform: `translateX(${clamp(sideMoved - sideMovedInit, -window.innerWidth, 0)}px)` } : { transition: "all .25s ease" }}
reply={
replyEventId && (
<Reply
Expand Down Expand Up @@ -1413,8 +1487,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={isTouchingSide && mEvent.getId() == swipingId ? { transform: `translateX(${clamp(sideMoved - sideMovedInit, -window.innerWidth, 0)}px)` } : { transition: "all .25s ease" }}
reactions={
reactionRelations && (
<Reactions
Expand Down Expand Up @@ -1824,4 +1902,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
67 changes: 61 additions & 6 deletions src/app/templates/client/Client.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ import Dialogs from '../../organisms/pw/Dialogs';

import initMatrix from '../../../client/initMatrix';
import navigation from '../../../client/state/navigation';
import { openNavigation } from '../../../client/action/navigation';
import cons from '../../../client/state/cons';

import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
import { MatrixClientProvider } from '../../hooks/useMatrixClient';
import { ClientContent } from './ClientContent';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { clamp } from '../../utils/common';

function SystemEmojiFeature() {
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
Expand All @@ -38,27 +40,80 @@ function SystemEmojiFeature() {
function Client() {
const [isLoading, changeLoading] = useState(true);
const [loadingMsg, setLoadingMsg] = useState('Heating up');
const [isTouchingSide, setTouchingSide] = useState(false);
const [sideMoved, setSideMoved] = useState(0);
const classNameHidden = 'client__item-hidden';
const classNameSided = 'client__item-sided';

const navWrapperRef = useRef(null);
const roomWrapperRef = useRef(null);

function onRoomSelected() {
navWrapperRef.current?.classList.add(classNameHidden);
roomWrapperRef.current?.classList.remove(classNameHidden);
navWrapperRef.current?.classList.add(classNameSided);
}
function onNavigationSelected() {
navWrapperRef.current?.classList.remove(classNameHidden);
roomWrapperRef.current?.classList.add(classNameHidden);
navWrapperRef.current?.classList.remove(classNameSided);
setTimeout(() => roomWrapperRef.current?.classList.add(classNameHidden), 250);
}
// Touch handlers for window object. If the touch starts at 10% of the left of the screen, it will trigger the swipe-right-menu.
let lastTouch = 0, sideVelocity = 0;
North-West-Wind marked this conversation as resolved.
Show resolved Hide resolved
function onTouchStart(event) {
North-West-Wind marked this conversation as resolved.
Show resolved Hide resolved
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);
lastTouch = Date.now();
}
}
function onTouchEnd(event) {
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();
}
sideVelocity = lastTouch = 0;
return 0;
});
}
return false;
});
}
function onTouchMove(event) {
if (!navWrapperRef.current?.classList.contains(classNameSided)) return;
setTouchingSide(isTouchingSide => {
if (isTouchingSide) {
event.preventDefault();
if (event.changedTouches.length != 1) return setSideMoved(0);
setSideMoved(sideMoved => {
const newSideMoved = event.changedTouches[0].clientX;
sideVelocity = (newSideMoved - sideMoved) / (Date.now() - lastTouch);
lastTouch = Date.now();
return newSideMoved;
});
}
return isTouchingSide;
});
}

useEffect(() => {
navigation.on(cons.events.navigation.ROOM_SELECTED, onRoomSelected);
navigation.on(cons.events.navigation.NAVIGATION_OPENED, onNavigationSelected);

window.addEventListener("touchstart", onTouchStart);
window.addEventListener("touchend", onTouchEnd);
window.addEventListener("touchmove", onTouchMove);

return () => {
navigation.removeListener(cons.events.navigation.ROOM_SELECTED, onRoomSelected);
navigation.removeListener(cons.events.navigation.NAVIGATION_OPENED, onNavigationSelected);

window.removeEventListener("touchstart", onTouchStart);
window.removeEventListener("touchend", onTouchEnd);
window.removeEventListener("touchmove", onTouchMove);
};
}, []);

Expand Down Expand Up @@ -119,10 +174,10 @@ function Client() {

return (
<MatrixClientProvider value={initMatrix.matrixClient}>
<div className="navigation__wrapper" style={isTouchingSide ? { transform: `translateX(${-clamp(window.innerWidth - sideMoved, 0, window.innerWidth)}px)`, transition: "none" } : {}} ref={navWrapperRef}>
<Navigation />
</div>
<div className="client-container">
<div className="navigation__wrapper" ref={navWrapperRef}>
<Navigation />
</div>
<div className={`room__wrapper ${classNameHidden}`} ref={roomWrapperRef}>
<ClientContent />
</div>
Expand Down
Loading
Loading