chore: update cinny submodule to latest commit with mobile gesture enhancements and disable route animation on compact view
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { MobileSwipeBackPanel } from './mobile/MobileSwipeBackPanel';
|
||||
import { useCompactNav } from '../hooks/useCompactNav';
|
||||
import { MobileSwipeGestureHost } from './mobile/MobileSwipeGestureHost';
|
||||
|
||||
/**
|
||||
* Wrapper for Outlet that adds route-based animation
|
||||
@@ -8,12 +9,13 @@ import { MobileSwipeBackPanel } from './mobile/MobileSwipeBackPanel';
|
||||
*/
|
||||
export function AnimatedOutlet() {
|
||||
const location = useLocation();
|
||||
const compact = useCompactNav();
|
||||
|
||||
return (
|
||||
<MobileSwipeBackPanel>
|
||||
<MobileSwipeGestureHost>
|
||||
<div
|
||||
key={location.pathname}
|
||||
data-route-transition="true"
|
||||
{...(!compact && { 'data-route-transition': 'true' })}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
@@ -25,6 +27,6 @@ export function AnimatedOutlet() {
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</MobileSwipeBackPanel>
|
||||
</MobileSwipeGestureHost>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCompactNav } from '../../hooks/useCompactNav';
|
||||
import { useBackRoute } from '../../hooks/useBackRoute';
|
||||
import {
|
||||
claimMobileGesture,
|
||||
clearMobileGesture,
|
||||
getActiveMobileGesture,
|
||||
} from './mobileGestureArbitration';
|
||||
import { useWindowPointerDrag } from './useWindowPointerDrag';
|
||||
import * as css from './mobile-gestures.css';
|
||||
|
||||
const COMMIT_RATIO = 0.28;
|
||||
const MIN_COMMIT_PX = 72;
|
||||
const MAX_START_Y_RATIO = 0.88;
|
||||
const DRAG_THRESHOLD = 8;
|
||||
|
||||
type DragState = {
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
moved: boolean;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
type MobileSwipeBackPanelProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const readTransformOffset = (el: HTMLElement | null): number => {
|
||||
if (!el) return 0;
|
||||
const transform = window.getComputedStyle(el).transform;
|
||||
if (!transform || transform === 'none') return 0;
|
||||
return new DOMMatrix(transform).m41;
|
||||
};
|
||||
|
||||
export function MobileSwipeBackPanel({ children }: MobileSwipeBackPanelProps) {
|
||||
const compact = useCompactNav();
|
||||
const { canGoBack, goBack } = useBackRoute();
|
||||
const enabled = compact && canGoBack;
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
|
||||
const resetTransform = useCallback((animate = true) => {
|
||||
const content = contentRef.current;
|
||||
if (!content) return;
|
||||
setAnimating(animate);
|
||||
content.style.transition = animate ? 'transform 0.22s cubic-bezier(0.4, 0, 0.2, 1)' : 'none';
|
||||
content.style.transform = 'translateX(0px)';
|
||||
if (dragRef.current) {
|
||||
dragRef.current.offset = 0;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setTransform = useCallback((px: number, animate = false) => {
|
||||
const content = contentRef.current;
|
||||
if (!content) return;
|
||||
if (dragRef.current) {
|
||||
dragRef.current.offset = px;
|
||||
}
|
||||
content.style.transition = animate
|
||||
? 'transform 0.22s cubic-bezier(0.4, 0, 0.2, 1)'
|
||||
: 'none';
|
||||
content.style.transform = `translateX(${px}px)`;
|
||||
}, []);
|
||||
|
||||
const commitBack = useCallback(() => {
|
||||
const width = rootRef.current?.clientWidth ?? window.innerWidth;
|
||||
setAnimating(true);
|
||||
setTransform(width, true);
|
||||
window.setTimeout(() => {
|
||||
goBack();
|
||||
resetTransform(false);
|
||||
setAnimating(false);
|
||||
}, 180);
|
||||
}, [goBack, resetTransform, setTransform]);
|
||||
|
||||
const shouldIgnoreTarget = (target: EventTarget | null): boolean => {
|
||||
if (!(target instanceof Element)) return true;
|
||||
return Boolean(
|
||||
target.closest(
|
||||
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], button, a, [role="button"], [data-carousel-scroller], [data-disable-swipe-back="true"]'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const releaseCapture = useCallback((pointerId: number) => {
|
||||
const root = rootRef.current;
|
||||
if (root?.hasPointerCapture(pointerId)) {
|
||||
root.releasePointerCapture(pointerId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const endDrag = useCallback(
|
||||
(pointerId: number) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== pointerId) return;
|
||||
|
||||
releaseCapture(pointerId);
|
||||
dragRef.current = null;
|
||||
clearMobileGesture(pointerId);
|
||||
|
||||
if (!drag.moved) {
|
||||
resetTransform(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const width = rootRef.current?.clientWidth ?? window.innerWidth;
|
||||
const currentOffset = Math.max(
|
||||
drag.offset,
|
||||
readTransformOffset(contentRef.current)
|
||||
);
|
||||
const shouldCommit = currentOffset >= Math.max(width * COMMIT_RATIO, MIN_COMMIT_PX);
|
||||
|
||||
if (shouldCommit) {
|
||||
commitBack();
|
||||
return;
|
||||
}
|
||||
|
||||
resetTransform(true);
|
||||
},
|
||||
[commitBack, releaseCapture, resetTransform]
|
||||
);
|
||||
|
||||
const processPointerMove = useCallback(
|
||||
(evt: { pointerId: number; clientX: number; clientY: number; preventDefault?: () => void }) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== evt.pointerId) return;
|
||||
|
||||
const activeGesture = getActiveMobileGesture(evt.pointerId);
|
||||
if (activeGesture && activeGesture !== 'back') return;
|
||||
|
||||
const deltaX = evt.clientX - drag.startX;
|
||||
const deltaY = evt.clientY - drag.startY;
|
||||
|
||||
if (!drag.moved) {
|
||||
if (Math.abs(deltaX) < DRAG_THRESHOLD && Math.abs(deltaY) < DRAG_THRESHOLD) return;
|
||||
if (Math.abs(deltaY) > Math.abs(deltaX)) {
|
||||
dragRef.current = null;
|
||||
clearMobileGesture(evt.pointerId);
|
||||
return;
|
||||
}
|
||||
if (deltaX <= 0) {
|
||||
dragRef.current = null;
|
||||
clearMobileGesture(evt.pointerId);
|
||||
return;
|
||||
}
|
||||
if (!claimMobileGesture('back', evt.pointerId)) return;
|
||||
|
||||
drag.moved = true;
|
||||
try {
|
||||
rootRef.current?.setPointerCapture(evt.pointerId);
|
||||
} catch {
|
||||
// Ignore capture failures on Android WebView.
|
||||
}
|
||||
}
|
||||
|
||||
evt.preventDefault?.();
|
||||
const width = rootRef.current?.clientWidth ?? window.innerWidth;
|
||||
setTransform(Math.min(Math.max(deltaX, 0), width), false);
|
||||
},
|
||||
[setTransform]
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!enabled || animating || evt.button !== 0 || !evt.isPrimary) return;
|
||||
if (shouldIgnoreTarget(evt.target)) return;
|
||||
if (evt.clientY > window.innerHeight * MAX_START_Y_RATIO) return;
|
||||
|
||||
dragRef.current = {
|
||||
pointerId: evt.pointerId,
|
||||
startX: evt.clientX,
|
||||
startY: evt.clientY,
|
||||
moved: false,
|
||||
offset: 0,
|
||||
};
|
||||
},
|
||||
[animating, enabled]
|
||||
);
|
||||
|
||||
const isActivePointer = useCallback((pointerId: number) => {
|
||||
return dragRef.current?.pointerId === pointerId;
|
||||
}, []);
|
||||
|
||||
useWindowPointerDrag({
|
||||
enabled,
|
||||
isActivePointer,
|
||||
onMove: processPointerMove,
|
||||
onEnd: endDrag,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
resetTransform(false);
|
||||
}, [enabled, resetTransform]);
|
||||
|
||||
if (!enabled) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={css.SwipeBackRoot}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={processPointerMove}
|
||||
onPointerUp={(evt) => endDrag(evt.pointerId)}
|
||||
onPointerCancel={(evt) => endDrag(evt.pointerId)}
|
||||
onLostPointerCapture={(evt) => endDrag(evt.pointerId)}
|
||||
>
|
||||
<div className={css.SwipeBackUnderlay} aria-hidden>
|
||||
<div className={css.SwipeBackSidebarPeek} />
|
||||
<div className={css.SwipeBackChannelPeek} />
|
||||
</div>
|
||||
<div ref={contentRef} className={css.SwipeBackContent}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
357
overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx
Normal file
357
overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||
import { useCompactNav } from '../../hooks/useCompactNav';
|
||||
import { useBackRoute } from '../../hooks/useBackRoute';
|
||||
import { startReplyToEvent } from '../../features/room/replyToMessage';
|
||||
import { mobileSwipeReplyBridgeRef } from './mobileSwipeReplyBridge';
|
||||
import * as css from './mobile-gestures.css';
|
||||
|
||||
const EDGE_WIDTH = 28;
|
||||
const BACK_COMMIT_RATIO = 0.25;
|
||||
const BACK_COMMIT_MIN = 56;
|
||||
const REPLY_THRESHOLD = 48;
|
||||
const REPLY_MAX = 96;
|
||||
const AXIS_LOCK_PX = 10;
|
||||
const AXIS_DOMINANCE = 1.2;
|
||||
const MAX_START_Y_RATIO = 0.88;
|
||||
|
||||
type GesturePhase = 'idle' | 'pending' | 'back' | 'reply';
|
||||
|
||||
type GestureState = {
|
||||
phase: GesturePhase;
|
||||
touchId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
messageEl: HTMLElement | null;
|
||||
messageId: string | null;
|
||||
edgeBack: boolean;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
const IDLE_STATE: GestureState = {
|
||||
phase: 'idle',
|
||||
touchId: -1,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
messageEl: null,
|
||||
messageId: null,
|
||||
edgeBack: false,
|
||||
offset: 0,
|
||||
};
|
||||
|
||||
type MobileSwipeGestureHostProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const shouldIgnoreBackTarget = (target: EventTarget | null): boolean => {
|
||||
if (!(target instanceof Element)) return true;
|
||||
return Boolean(
|
||||
target.closest(
|
||||
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-back="true"]'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const shouldIgnoreReplyTarget = (target: EventTarget | null): boolean => {
|
||||
if (!(target instanceof Element)) return true;
|
||||
return Boolean(
|
||||
target.closest(
|
||||
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-reply="true"]'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const findMessageElement = (target: EventTarget | null): HTMLElement | null => {
|
||||
if (!(target instanceof Element)) return null;
|
||||
return target.closest('[data-message-id]') as HTMLElement | null;
|
||||
};
|
||||
|
||||
const applyBackOffset = (dx: number, width: number): number => {
|
||||
const positive = Math.max(0, dx);
|
||||
if (positive <= width) return positive;
|
||||
return width + (positive - width) * 0.22;
|
||||
};
|
||||
|
||||
const setElementTransform = (el: HTMLElement | null, px: number) => {
|
||||
if (!el) return;
|
||||
el.style.transition = 'none';
|
||||
el.style.transform = `translateX(${px}px)`;
|
||||
};
|
||||
|
||||
const resetElementTransform = (el: HTMLElement | null) => {
|
||||
if (!el) return;
|
||||
el.style.transition = 'none';
|
||||
el.style.transform = '';
|
||||
};
|
||||
|
||||
export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps) {
|
||||
const compact = useCompactNav();
|
||||
const { canGoBack, goBack } = useBackRoute();
|
||||
const backEnabled = compact && canGoBack;
|
||||
const replyEnabled = compact;
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const gestureRef = useRef<GestureState>({ ...IDLE_STATE });
|
||||
const scrollLockRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const unlockScroll = useCallback(() => {
|
||||
if (scrollLockRef.current) {
|
||||
scrollLockRef.current.style.overflow = '';
|
||||
scrollLockRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const lockScroll = useCallback((anchor: HTMLElement) => {
|
||||
unlockScroll();
|
||||
let el: HTMLElement | null = anchor;
|
||||
while (el && el !== document.body) {
|
||||
const style = window.getComputedStyle(el);
|
||||
if (
|
||||
(style.overflowY === 'auto' || style.overflowY === 'scroll') &&
|
||||
el.scrollHeight > el.clientHeight + 1
|
||||
) {
|
||||
scrollLockRef.current = el;
|
||||
el.style.overflow = 'hidden';
|
||||
return;
|
||||
}
|
||||
el = el.parentElement;
|
||||
}
|
||||
}, [unlockScroll]);
|
||||
|
||||
const resetBackTransform = useCallback(() => {
|
||||
resetElementTransform(contentRef.current);
|
||||
document.documentElement.classList.remove('mobile-gesture-lock');
|
||||
}, []);
|
||||
|
||||
const resetReplyTransform = useCallback((messageEl?: HTMLElement | null) => {
|
||||
const bridge = mobileSwipeReplyBridgeRef.current;
|
||||
const el = messageEl ?? gestureRef.current.messageEl;
|
||||
resetElementTransform(el);
|
||||
bridge?.setIndicator(null, false);
|
||||
document.documentElement.classList.remove('mobile-gesture-lock');
|
||||
}, []);
|
||||
|
||||
const resetGesture = useCallback(
|
||||
(messageEl?: HTMLElement | null) => {
|
||||
resetBackTransform();
|
||||
resetReplyTransform(messageEl);
|
||||
unlockScroll();
|
||||
gestureRef.current = { ...IDLE_STATE };
|
||||
},
|
||||
[resetBackTransform, resetReplyTransform, unlockScroll]
|
||||
);
|
||||
|
||||
const updateReplyIndicator = useCallback((messageEl: HTMLElement, offset: number) => {
|
||||
const bridge = mobileSwipeReplyBridgeRef.current;
|
||||
if (!bridge?.layerEl) return;
|
||||
const messageRect = messageEl.getBoundingClientRect();
|
||||
const layerRect = bridge.layerEl.getBoundingClientRect();
|
||||
const top = messageRect.top - layerRect.top + messageRect.height / 2 - 18;
|
||||
bridge.setIndicator(top, Math.abs(offset) >= REPLY_THRESHOLD * 0.6);
|
||||
}, []);
|
||||
|
||||
const lockGesture = useCallback(
|
||||
(phase: 'back' | 'reply', scrollAnchor?: HTMLElement | null) => {
|
||||
gestureRef.current.phase = phase;
|
||||
document.documentElement.classList.add('mobile-gesture-lock');
|
||||
if (scrollAnchor) {
|
||||
lockScroll(scrollAnchor);
|
||||
}
|
||||
},
|
||||
[lockScroll]
|
||||
);
|
||||
|
||||
const finishGesture = useCallback(() => {
|
||||
const gesture = gestureRef.current;
|
||||
if (gesture.phase === 'idle' || gesture.phase === 'pending') {
|
||||
resetGesture();
|
||||
return;
|
||||
}
|
||||
|
||||
if (gesture.phase === 'back') {
|
||||
const width = rootRef.current?.clientWidth ?? window.innerWidth;
|
||||
const shouldCommit =
|
||||
gesture.offset >= Math.max(width * BACK_COMMIT_RATIO, BACK_COMMIT_MIN);
|
||||
|
||||
resetGesture();
|
||||
if (shouldCommit) {
|
||||
goBack();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (gesture.phase === 'reply' && gesture.messageEl && gesture.messageId) {
|
||||
const bridge = mobileSwipeReplyBridgeRef.current;
|
||||
const shouldReply = Math.abs(gesture.offset) >= REPLY_THRESHOLD;
|
||||
const { messageEl, messageId } = gesture;
|
||||
|
||||
resetGesture(messageEl);
|
||||
|
||||
if (shouldReply && bridge) {
|
||||
startReplyToEvent(bridge.room, messageId, bridge.setReplyDraft, bridge.editor);
|
||||
}
|
||||
}
|
||||
}, [goBack, resetGesture]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!compact) return;
|
||||
|
||||
const getActiveTouch = (evt: TouchEvent, touchId: number) =>
|
||||
Array.from(evt.touches).find((touch) => touch.identifier === touchId);
|
||||
|
||||
const getEndedTouch = (evt: TouchEvent, touchId: number) =>
|
||||
Array.from(evt.changedTouches).find((touch) => touch.identifier === touchId);
|
||||
|
||||
const handleTouchStart = (evt: TouchEvent) => {
|
||||
if (gestureRef.current.phase !== 'idle') return;
|
||||
|
||||
const touch = evt.changedTouches[0];
|
||||
if (!touch) return;
|
||||
|
||||
const target = evt.target;
|
||||
if (touch.clientY > window.innerHeight * MAX_START_Y_RATIO) return;
|
||||
|
||||
const messageEl = findMessageElement(target);
|
||||
const messageId = messageEl?.getAttribute('data-message-id') ?? null;
|
||||
|
||||
const canStartBack = backEnabled && !shouldIgnoreBackTarget(target);
|
||||
const canStartReply =
|
||||
replyEnabled &&
|
||||
messageEl &&
|
||||
messageId &&
|
||||
!shouldIgnoreReplyTarget(target) &&
|
||||
mobileSwipeReplyBridgeRef.current;
|
||||
|
||||
if (!canStartBack && !canStartReply) return;
|
||||
|
||||
if (touch.clientX <= EDGE_WIDTH && canStartBack) {
|
||||
evt.preventDefault();
|
||||
gestureRef.current = {
|
||||
phase: 'back',
|
||||
touchId: touch.identifier,
|
||||
startX: touch.clientX,
|
||||
startY: touch.clientY,
|
||||
messageEl: null,
|
||||
messageId: null,
|
||||
edgeBack: true,
|
||||
offset: 0,
|
||||
};
|
||||
document.documentElement.classList.add('mobile-gesture-lock');
|
||||
return;
|
||||
}
|
||||
|
||||
gestureRef.current = {
|
||||
phase: 'pending',
|
||||
touchId: touch.identifier,
|
||||
startX: touch.clientX,
|
||||
startY: touch.clientY,
|
||||
messageEl: canStartReply ? messageEl : null,
|
||||
messageId: canStartReply ? messageId : null,
|
||||
edgeBack: false,
|
||||
offset: 0,
|
||||
};
|
||||
};
|
||||
|
||||
const handleTouchMove = (evt: TouchEvent) => {
|
||||
const gesture = gestureRef.current;
|
||||
if (gesture.phase === 'idle') return;
|
||||
|
||||
const touch = getActiveTouch(evt, gesture.touchId);
|
||||
if (!touch) return;
|
||||
|
||||
const deltaX = touch.clientX - gesture.startX;
|
||||
const deltaY = touch.clientY - gesture.startY;
|
||||
|
||||
if (gesture.phase === 'pending') {
|
||||
if (Math.abs(deltaX) < AXIS_LOCK_PX && Math.abs(deltaY) < AXIS_LOCK_PX) return;
|
||||
|
||||
const horizontal = Math.abs(deltaX) >= Math.abs(deltaY) * AXIS_DOMINANCE;
|
||||
const vertical = Math.abs(deltaY) >= Math.abs(deltaX) * AXIS_DOMINANCE;
|
||||
|
||||
if (vertical) {
|
||||
resetGesture();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!horizontal) return;
|
||||
|
||||
if (deltaX > 0 && backEnabled) {
|
||||
lockGesture('back', contentRef.current);
|
||||
} else if (deltaX < 0 && gesture.messageEl && gesture.messageId) {
|
||||
lockGesture('reply', gesture.messageEl);
|
||||
} else {
|
||||
resetGesture();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (gesture.phase === 'back') {
|
||||
if (!evt.cancelable) return;
|
||||
evt.preventDefault();
|
||||
|
||||
const width = rootRef.current?.clientWidth ?? window.innerWidth;
|
||||
const offset = applyBackOffset(deltaX, width);
|
||||
gesture.offset = offset;
|
||||
setElementTransform(contentRef.current, offset);
|
||||
return;
|
||||
}
|
||||
|
||||
if (gesture.phase === 'reply' && gesture.messageEl) {
|
||||
if (!evt.cancelable) return;
|
||||
evt.preventDefault();
|
||||
|
||||
const offset = Math.max(deltaX, -REPLY_MAX);
|
||||
gesture.offset = offset;
|
||||
setElementTransform(gesture.messageEl, offset);
|
||||
updateReplyIndicator(gesture.messageEl, offset);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = (evt: TouchEvent) => {
|
||||
const gesture = gestureRef.current;
|
||||
if (gesture.phase === 'idle') return;
|
||||
|
||||
const touch = getEndedTouch(evt, gesture.touchId);
|
||||
if (!touch) return;
|
||||
|
||||
finishGesture();
|
||||
};
|
||||
|
||||
document.addEventListener('touchstart', handleTouchStart, { capture: true, passive: false });
|
||||
document.addEventListener('touchmove', handleTouchMove, { capture: true, passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd, { capture: true });
|
||||
document.addEventListener('touchcancel', handleTouchEnd, { capture: true });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('touchstart', handleTouchStart, { capture: true });
|
||||
document.removeEventListener('touchmove', handleTouchMove, { capture: true });
|
||||
document.removeEventListener('touchend', handleTouchEnd, { capture: true });
|
||||
document.removeEventListener('touchcancel', handleTouchEnd, { capture: true });
|
||||
resetGesture();
|
||||
};
|
||||
}, [backEnabled, compact, finishGesture, lockGesture, replyEnabled, resetGesture, updateReplyIndicator]);
|
||||
|
||||
useEffect(() => {
|
||||
resetGesture();
|
||||
}, [canGoBack, resetGesture]);
|
||||
|
||||
if (!compact) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const showBackChrome = backEnabled;
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={css.SwipeBackRoot}>
|
||||
{showBackChrome && (
|
||||
<div className={css.SwipeBackUnderlay} aria-hidden>
|
||||
<div className={css.SwipeBackSidebarPeek} />
|
||||
<div className={css.SwipeBackChannelPeek} />
|
||||
</div>
|
||||
)}
|
||||
<div ref={contentRef} className={css.SwipeBackContent}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,13 @@
|
||||
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { ReactNode, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { Editor } from 'slate';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { Icon, Icons } from '../icons';
|
||||
import { useCompactNav } from '../../hooks/useCompactNav';
|
||||
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||
import { startReplyToEvent } from '../../features/room/replyToMessage';
|
||||
import {
|
||||
claimMobileGesture,
|
||||
clearMobileGesture,
|
||||
getActiveMobileGesture,
|
||||
} from './mobileGestureArbitration';
|
||||
import { useWindowPointerDrag } from './useWindowPointerDrag';
|
||||
import { mobileSwipeReplyBridgeRef } from './mobileSwipeReplyBridge';
|
||||
import * as css from './mobile-gestures.css';
|
||||
|
||||
const SWIPE_THRESHOLD = 56;
|
||||
const MAX_SWIPE = 88;
|
||||
const DRAG_THRESHOLD = 8;
|
||||
|
||||
type DragState = {
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
messageEl: HTMLElement;
|
||||
messageId: string;
|
||||
moved: boolean;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
type MobileSwipeToReplyLayerProps = {
|
||||
room: Room;
|
||||
editor: Editor;
|
||||
@@ -36,183 +16,40 @@ type MobileSwipeToReplyLayerProps = {
|
||||
|
||||
export function MobileSwipeToReplyLayer({ room, editor, children }: MobileSwipeToReplyLayerProps) {
|
||||
const compact = useCompactNav();
|
||||
const enabled = compact;
|
||||
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
|
||||
const layerRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const [indicatorTop, setIndicatorTop] = useState<number | null>(null);
|
||||
const [indicatorActive, setIndicatorActive] = useState(false);
|
||||
|
||||
const clearMessageTransform = useCallback((el?: HTMLElement | null, animate = true) => {
|
||||
const target = el ?? dragRef.current?.messageEl;
|
||||
if (!target) return;
|
||||
target.style.transition = animate ? 'transform 0.18s cubic-bezier(0.4, 0, 0.2, 1)' : 'none';
|
||||
target.style.transform = 'translateX(0px)';
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
if (!compact) return;
|
||||
|
||||
const releaseCapture = useCallback((pointerId: number) => {
|
||||
const layer = layerRef.current;
|
||||
if (layer?.hasPointerCapture(pointerId)) {
|
||||
layer.releasePointerCapture(pointerId);
|
||||
}
|
||||
}, []);
|
||||
mobileSwipeReplyBridgeRef.current = {
|
||||
room,
|
||||
editor,
|
||||
setReplyDraft,
|
||||
layerEl: layerRef.current,
|
||||
setIndicator: (top, active) => {
|
||||
setIndicatorTop(top);
|
||||
setIndicatorActive(active);
|
||||
},
|
||||
};
|
||||
|
||||
const resetGesture = useCallback(
|
||||
(pointerId?: number) => {
|
||||
if (pointerId !== undefined) {
|
||||
releaseCapture(pointerId);
|
||||
clearMobileGesture(pointerId);
|
||||
return () => {
|
||||
if (mobileSwipeReplyBridgeRef.current?.room.roomId === room.roomId) {
|
||||
mobileSwipeReplyBridgeRef.current = null;
|
||||
}
|
||||
clearMessageTransform();
|
||||
dragRef.current = null;
|
||||
setIndicatorTop(null);
|
||||
setIndicatorActive(false);
|
||||
},
|
||||
[clearMessageTransform, releaseCapture]
|
||||
);
|
||||
};
|
||||
}, [compact, editor, room, setReplyDraft]);
|
||||
|
||||
const shouldIgnoreTarget = (target: EventTarget | null): boolean => {
|
||||
if (!(target instanceof Element)) return true;
|
||||
return Boolean(
|
||||
target.closest(
|
||||
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-reply="true"]'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const findMessageElement = (target: EventTarget | null): HTMLElement | null => {
|
||||
if (!(target instanceof Element)) return null;
|
||||
return target.closest('[data-message-id]') as HTMLElement | null;
|
||||
};
|
||||
|
||||
const updateIndicator = useCallback((messageEl: HTMLElement, offset: number) => {
|
||||
const layer = layerRef.current;
|
||||
if (!layer) return;
|
||||
const messageRect = messageEl.getBoundingClientRect();
|
||||
const layerRect = layer.getBoundingClientRect();
|
||||
setIndicatorTop(messageRect.top - layerRect.top + messageRect.height / 2 - 18);
|
||||
setIndicatorActive(Math.abs(offset) >= SWIPE_THRESHOLD * 0.65);
|
||||
}, []);
|
||||
|
||||
const endDrag = useCallback(
|
||||
(pointerId: number) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== pointerId) return;
|
||||
|
||||
releaseCapture(pointerId);
|
||||
const { messageEl, messageId, moved, offset } = drag;
|
||||
dragRef.current = null;
|
||||
clearMobileGesture(pointerId);
|
||||
|
||||
if (!moved) {
|
||||
resetGesture();
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldReply = Math.abs(offset) >= SWIPE_THRESHOLD;
|
||||
clearMessageTransform(messageEl, true);
|
||||
setIndicatorTop(null);
|
||||
setIndicatorActive(false);
|
||||
|
||||
if (shouldReply) {
|
||||
startReplyToEvent(room, messageId, setReplyDraft, editor);
|
||||
}
|
||||
},
|
||||
[clearMessageTransform, editor, releaseCapture, resetGesture, room, setReplyDraft]
|
||||
);
|
||||
|
||||
const processPointerMove = useCallback(
|
||||
(evt: { pointerId: number; clientX: number; clientY: number; preventDefault?: () => void }) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== evt.pointerId) return;
|
||||
|
||||
const activeGesture = getActiveMobileGesture(evt.pointerId);
|
||||
if (activeGesture && activeGesture !== 'reply') return;
|
||||
|
||||
const deltaX = evt.clientX - drag.startX;
|
||||
const deltaY = evt.clientY - drag.startY;
|
||||
|
||||
if (!drag.moved) {
|
||||
if (Math.abs(deltaX) < DRAG_THRESHOLD && Math.abs(deltaY) < DRAG_THRESHOLD) return;
|
||||
if (Math.abs(deltaY) > Math.abs(deltaX)) {
|
||||
resetGesture(evt.pointerId);
|
||||
return;
|
||||
}
|
||||
if (deltaX >= 0) {
|
||||
resetGesture(evt.pointerId);
|
||||
return;
|
||||
}
|
||||
if (!claimMobileGesture('reply', evt.pointerId)) return;
|
||||
|
||||
drag.moved = true;
|
||||
try {
|
||||
layerRef.current?.setPointerCapture(evt.pointerId);
|
||||
} catch {
|
||||
// Ignore capture failures on Android WebView.
|
||||
}
|
||||
}
|
||||
|
||||
evt.preventDefault?.();
|
||||
const offset = Math.max(deltaX, -MAX_SWIPE);
|
||||
drag.offset = offset;
|
||||
drag.messageEl.style.transition = 'none';
|
||||
drag.messageEl.style.transform = `translateX(${offset}px)`;
|
||||
updateIndicator(drag.messageEl, offset);
|
||||
},
|
||||
[resetGesture, updateIndicator]
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!enabled || evt.button !== 0 || !evt.isPrimary) return;
|
||||
if (shouldIgnoreTarget(evt.target)) return;
|
||||
|
||||
const messageEl = findMessageElement(evt.target);
|
||||
const messageId = messageEl?.getAttribute('data-message-id');
|
||||
if (!messageEl || !messageId) return;
|
||||
|
||||
dragRef.current = {
|
||||
pointerId: evt.pointerId,
|
||||
startX: evt.clientX,
|
||||
startY: evt.clientY,
|
||||
messageEl,
|
||||
messageId,
|
||||
moved: false,
|
||||
offset: 0,
|
||||
};
|
||||
},
|
||||
[enabled]
|
||||
);
|
||||
|
||||
const isActivePointer = useCallback((pointerId: number) => {
|
||||
return dragRef.current?.pointerId === pointerId;
|
||||
}, []);
|
||||
|
||||
useWindowPointerDrag({
|
||||
enabled,
|
||||
isActivePointer,
|
||||
onMove: processPointerMove,
|
||||
onEnd: endDrag,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
resetGesture();
|
||||
}, [room.roomId, resetGesture]);
|
||||
|
||||
if (!enabled) {
|
||||
if (!compact) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={layerRef}
|
||||
className={css.SwipeToReplyLayer}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={processPointerMove}
|
||||
onPointerUp={(evt) => endDrag(evt.pointerId)}
|
||||
onPointerCancel={(evt) => endDrag(evt.pointerId)}
|
||||
onLostPointerCapture={(evt) => endDrag(evt.pointerId)}
|
||||
>
|
||||
<div ref={layerRef} className={css.SwipeToReplyLayer}>
|
||||
{children}
|
||||
{indicatorTop !== null && (
|
||||
<div
|
||||
|
||||
@@ -9,6 +9,19 @@ export const SwipeBackRoot = style({
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
export const SwipeBackContent = style({
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: color.Background.Container,
|
||||
boxShadow: '0 0 24px rgba(0, 0, 0, 0.35)',
|
||||
willChange: 'transform',
|
||||
touchAction: 'pan-y',
|
||||
});
|
||||
|
||||
@@ -33,26 +46,12 @@ export const SwipeBackChannelPeek = style({
|
||||
backgroundColor: color.Background.Container,
|
||||
});
|
||||
|
||||
export const SwipeBackContent = style({
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: color.Background.Container,
|
||||
boxShadow: '0 0 24px rgba(0, 0, 0, 0.35)',
|
||||
willChange: 'transform',
|
||||
});
|
||||
|
||||
export const SwipeToReplyLayer = style({
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
touchAction: 'pan-y',
|
||||
});
|
||||
|
||||
export const SwipeToReplyIndicator = style({
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
export type MobileGestureKind = 'back' | 'reply';
|
||||
|
||||
type ActiveGesture = {
|
||||
kind: MobileGestureKind;
|
||||
pointerId: number;
|
||||
};
|
||||
|
||||
let activeGesture: ActiveGesture | null = null;
|
||||
|
||||
export const claimMobileGesture = (kind: MobileGestureKind, pointerId: number): boolean => {
|
||||
if (!activeGesture || activeGesture.pointerId === pointerId) {
|
||||
activeGesture = { kind, pointerId };
|
||||
return true;
|
||||
}
|
||||
return activeGesture.kind === kind && activeGesture.pointerId === pointerId;
|
||||
};
|
||||
|
||||
export const getActiveMobileGesture = (pointerId: number): MobileGestureKind | null => {
|
||||
if (!activeGesture || activeGesture.pointerId !== pointerId) return null;
|
||||
return activeGesture.kind;
|
||||
};
|
||||
|
||||
export const clearMobileGesture = (pointerId: number) => {
|
||||
if (activeGesture?.pointerId === pointerId) {
|
||||
activeGesture = null;
|
||||
}
|
||||
};
|
||||
15
overlay/src/app/components/mobile/mobileSwipeReplyBridge.ts
Normal file
15
overlay/src/app/components/mobile/mobileSwipeReplyBridge.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { Editor } from 'slate';
|
||||
import { IReplyDraft } from '../../state/room/roomInputDrafts';
|
||||
|
||||
export type MobileSwipeReplyBridge = {
|
||||
room: Room;
|
||||
editor: Editor;
|
||||
setReplyDraft: (draft: IReplyDraft | undefined) => void;
|
||||
layerEl: HTMLElement | null;
|
||||
setIndicator: (top: number | null, active: boolean) => void;
|
||||
};
|
||||
|
||||
export const mobileSwipeReplyBridgeRef: { current: MobileSwipeReplyBridge | null } = {
|
||||
current: null,
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
type PointerLikeEvent = {
|
||||
pointerId: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
preventDefault?: () => void;
|
||||
};
|
||||
|
||||
type UseWindowPointerDragOptions = {
|
||||
enabled: boolean;
|
||||
isActivePointer: (pointerId: number) => boolean;
|
||||
onMove: (evt: PointerLikeEvent) => void;
|
||||
onEnd: (pointerId: number) => void;
|
||||
};
|
||||
|
||||
export const useWindowPointerDrag = ({
|
||||
enabled,
|
||||
isActivePointer,
|
||||
onMove,
|
||||
onEnd,
|
||||
}: UseWindowPointerDragOptions) => {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const handlePointerMove = (evt: PointerEvent) => {
|
||||
if (!isActivePointer(evt.pointerId)) return;
|
||||
onMove(evt);
|
||||
};
|
||||
|
||||
const handlePointerEnd = (evt: PointerEvent) => {
|
||||
if (!isActivePointer(evt.pointerId)) return;
|
||||
onEnd(evt.pointerId);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove, true);
|
||||
window.addEventListener('pointerup', handlePointerEnd, true);
|
||||
window.addEventListener('pointercancel', handlePointerEnd, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove, true);
|
||||
window.removeEventListener('pointerup', handlePointerEnd, true);
|
||||
window.removeEventListener('pointercancel', handlePointerEnd, true);
|
||||
};
|
||||
}, [enabled, isActivePointer, onEnd, onMove]);
|
||||
};
|
||||
@@ -184,6 +184,23 @@ body.mocha-theme {
|
||||
animation: fadeSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Disable route slide animation on compact/mobile — conflicts with swipe gestures */
|
||||
@media (max-width: 750px) {
|
||||
[data-route-transition='true'] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
html.mobile-gesture-lock,
|
||||
html.mobile-gesture-lock body {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
html.mobile-gesture-lock [data-timeline-scroll] {
|
||||
overflow: hidden !important;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* Twilight theme enhanced transitions */
|
||||
.twilight-theme [data-route-transition="true"] {
|
||||
animation: fadeSlideIn 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
Reference in New Issue
Block a user