diff --git a/overlay/src/app/components/AnimatedOutlet.tsx b/overlay/src/app/components/AnimatedOutlet.tsx index 9ec9cf3..0f04017 100644 --- a/overlay/src/app/components/AnimatedOutlet.tsx +++ b/overlay/src/app/components/AnimatedOutlet.tsx @@ -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 ( - +
-
+ ); } diff --git a/overlay/src/app/components/mobile/MobileSwipeBackPanel.tsx b/overlay/src/app/components/mobile/MobileSwipeBackPanel.tsx deleted file mode 100644 index 2737b68..0000000 --- a/overlay/src/app/components/mobile/MobileSwipeBackPanel.tsx +++ /dev/null @@ -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(null); - const contentRef = useRef(null); - const dragRef = useRef(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) => { - 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 ( -
endDrag(evt.pointerId)} - onPointerCancel={(evt) => endDrag(evt.pointerId)} - onLostPointerCapture={(evt) => endDrag(evt.pointerId)} - > -
-
-
-
-
- {children} -
-
- ); -} diff --git a/overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx b/overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx new file mode 100644 index 0000000..c1880ac --- /dev/null +++ b/overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx @@ -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(null); + const contentRef = useRef(null); + const gestureRef = useRef({ ...IDLE_STATE }); + const scrollLockRef = useRef(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 ( +
+ {showBackChrome && ( +
+
+
+
+ )} +
+ {children} +
+
+ ); +} diff --git a/overlay/src/app/components/mobile/MobileSwipeToReplyLayer.tsx b/overlay/src/app/components/mobile/MobileSwipeToReplyLayer.tsx index 402cda9..8d321a4 100644 --- a/overlay/src/app/components/mobile/MobileSwipeToReplyLayer.tsx +++ b/overlay/src/app/components/mobile/MobileSwipeToReplyLayer.tsx @@ -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(null); - const dragRef = useRef(null); const [indicatorTop, setIndicatorTop] = useState(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) => { - 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 ( -
endDrag(evt.pointerId)} - onPointerCancel={(evt) => endDrag(evt.pointerId)} - onLostPointerCapture={(evt) => endDrag(evt.pointerId)} - > +
{children} {indicatorTop !== null && (
{ - 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; - } -}; diff --git a/overlay/src/app/components/mobile/mobileSwipeReplyBridge.ts b/overlay/src/app/components/mobile/mobileSwipeReplyBridge.ts new file mode 100644 index 0000000..851a776 --- /dev/null +++ b/overlay/src/app/components/mobile/mobileSwipeReplyBridge.ts @@ -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, +}; diff --git a/overlay/src/app/components/mobile/useWindowPointerDrag.ts b/overlay/src/app/components/mobile/useWindowPointerDrag.ts deleted file mode 100644 index 8130f8b..0000000 --- a/overlay/src/app/components/mobile/useWindowPointerDrag.ts +++ /dev/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]); -}; diff --git a/overlay/src/index.css b/overlay/src/index.css index 7aa5f36..8a1b86b 100644 --- a/overlay/src/index.css +++ b/overlay/src/index.css @@ -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);