From 83b4cbbd8e66a1e65c732ab610b22468cf9a761a Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Thu, 9 Jul 2026 17:42:28 +1000 Subject: [PATCH] chore: update cinny submodule to latest commit and modify versioning for Android app --- android/app/build.gradle | 4 +- .../mobile/MobileSwipeBackPanel.tsx | 184 +++++++++------ .../mobile/MobileSwipeToReplyLayer.tsx | 219 ++++++++++-------- .../components/mobile/mobile-gestures.css.ts | 1 + .../mobile/mobileGestureArbitration.ts | 27 +++ .../components/mobile/useWindowPointerDrag.ts | 46 ++++ 6 files changed, 305 insertions(+), 176 deletions(-) create mode 100644 overlay/src/app/components/mobile/mobileGestureArbitration.ts create mode 100644 overlay/src/app/components/mobile/useWindowPointerDrag.ts diff --git a/android/app/build.gradle b/android/app/build.gradle index 14dcf07..13c3d6f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -27,8 +27,8 @@ android { applicationId "com.paarrot.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1783526741 - versionName "2026-07-08.160541.766" + versionCode 1783582912 + versionName "2026-07-09.074152.772" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/overlay/src/app/components/mobile/MobileSwipeBackPanel.tsx b/overlay/src/app/components/mobile/MobileSwipeBackPanel.tsx index 6b5be49..2737b68 100644 --- a/overlay/src/app/components/mobile/MobileSwipeBackPanel.tsx +++ b/overlay/src/app/components/mobile/MobileSwipeBackPanel.tsx @@ -1,24 +1,38 @@ 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; - dragging: boolean; 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(); @@ -27,22 +41,25 @@ export function MobileSwipeBackPanel({ children }: MobileSwipeBackPanelProps) { const rootRef = useRef(null); const contentRef = useRef(null); const dragRef = useRef(null); - const [offset, setOffset] = useState(0); const [animating, setAnimating] = useState(false); const resetTransform = useCallback((animate = true) => { const content = contentRef.current; if (!content) return; setAnimating(animate); - setOffset(0); 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; - setOffset(px); + if (dragRef.current) { + dragRef.current.offset = px; + } content.style.transition = animate ? 'transform 0.22s cubic-bezier(0.4, 0, 0.2, 1)' : 'none'; @@ -69,6 +86,84 @@ export function MobileSwipeBackPanel({ children }: MobileSwipeBackPanelProps) { ); }; + 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; @@ -79,77 +174,23 @@ export function MobileSwipeBackPanel({ children }: MobileSwipeBackPanelProps) { pointerId: evt.pointerId, startX: evt.clientX, startY: evt.clientY, - dragging: true, moved: false, + offset: 0, }; - - rootRef.current?.setPointerCapture(evt.pointerId); }, [animating, enabled] ); - const handlePointerMove = useCallback( - (evt: React.PointerEvent) => { - const drag = dragRef.current; - if (!drag || !drag.dragging || drag.pointerId !== evt.pointerId) return; + const isActivePointer = useCallback((pointerId: number) => { + return dragRef.current?.pointerId === pointerId; + }, []); - const deltaX = evt.clientX - drag.startX; - const deltaY = evt.clientY - drag.startY; - - if (!drag.moved) { - if (Math.abs(deltaX) < 8 && Math.abs(deltaY) < 8) return; - if (Math.abs(deltaY) > Math.abs(deltaX)) { - dragRef.current = null; - return; - } - if (deltaX <= 0) { - dragRef.current = null; - return; - } - drag.moved = true; - } - - evt.preventDefault(); - const width = rootRef.current?.clientWidth ?? window.innerWidth; - setTransform(Math.min(Math.max(deltaX, 0), width), false); - }, - [setTransform] - ); - - const endDrag = useCallback( - (pointerId: number) => { - const drag = dragRef.current; - if (!drag || drag.pointerId !== pointerId) return; - - if (rootRef.current?.hasPointerCapture(pointerId)) { - rootRef.current.releasePointerCapture(pointerId); - } - - dragRef.current = null; - - if (!drag.moved) { - resetTransform(false); - return; - } - - const width = rootRef.current?.clientWidth ?? window.innerWidth; - const shouldCommit = offset >= Math.max(width * COMMIT_RATIO, MIN_COMMIT_PX); - if (shouldCommit) { - commitBack(); - return; - } - - resetTransform(true); - }, - [commitBack, offset, resetTransform] - ); - - const handlePointerUp = useCallback( - (evt: React.PointerEvent) => { - endDrag(evt.pointerId); - }, - [endDrag] - ); + useWindowPointerDrag({ + enabled, + isActivePointer, + onMove: processPointerMove, + onEnd: endDrag, + }); useEffect(() => { resetTransform(false); @@ -164,9 +205,10 @@ export function MobileSwipeBackPanel({ children }: MobileSwipeBackPanelProps) { ref={rootRef} className={css.SwipeBackRoot} onPointerDown={handlePointerDown} - onPointerMove={handlePointerMove} - onPointerUp={handlePointerUp} - onPointerCancel={handlePointerUp} + onPointerMove={processPointerMove} + onPointerUp={(evt) => endDrag(evt.pointerId)} + onPointerCancel={(evt) => endDrag(evt.pointerId)} + onLostPointerCapture={(evt) => endDrag(evt.pointerId)} >
diff --git a/overlay/src/app/components/mobile/MobileSwipeToReplyLayer.tsx b/overlay/src/app/components/mobile/MobileSwipeToReplyLayer.tsx index cfaacdf..402cda9 100644 --- a/overlay/src/app/components/mobile/MobileSwipeToReplyLayer.tsx +++ b/overlay/src/app/components/mobile/MobileSwipeToReplyLayer.tsx @@ -6,10 +6,17 @@ 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 * as css from './mobile-gestures.css'; const SWIPE_THRESHOLD = 56; const MAX_SWIPE = 88; +const DRAG_THRESHOLD = 8; type DragState = { pointerId: number; @@ -18,6 +25,7 @@ type DragState = { messageEl: HTMLElement; messageId: string; moved: boolean; + offset: number; }; type MobileSwipeToReplyLayerProps = { @@ -42,38 +50,34 @@ export function MobileSwipeToReplyLayer({ room, editor, children }: MobileSwipeT target.style.transform = 'translateX(0px)'; }, []); - const resetGesture = useCallback(() => { - clearMessageTransform(); - dragRef.current = null; - setIndicatorTop(null); - setIndicatorActive(false); - }, [clearMessageTransform]); + const releaseCapture = useCallback((pointerId: number) => { + const layer = layerRef.current; + if (layer?.hasPointerCapture(pointerId)) { + layer.releasePointerCapture(pointerId); + } + }, []); + + const resetGesture = useCallback( + (pointerId?: number) => { + if (pointerId !== undefined) { + releaseCapture(pointerId); + clearMobileGesture(pointerId); + } + clearMessageTransform(); + dragRef.current = null; + setIndicatorTop(null); + setIndicatorActive(false); + }, + [clearMessageTransform, releaseCapture] + ); const shouldIgnoreTarget = (target: EventTarget | null): boolean => { if (!(target instanceof Element)) return true; - if ( + return Boolean( target.closest( - 'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], button, a, [role="button"], [data-disable-swipe-reply="true"]' + 'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-reply="true"]' ) - ) { - return true; - } - - let el: Element | null = target; - while (el && layerRef.current?.contains(el)) { - if (el instanceof HTMLElement) { - const { overflowX } = window.getComputedStyle(el); - if ( - (overflowX === 'auto' || overflowX === 'scroll') && - el.scrollWidth > el.clientWidth + 8 - ) { - return true; - } - } - el = el.parentElement; - } - - return false; + ); }; const findMessageElement = (target: EventTarget | null): HTMLElement | null => { @@ -90,6 +94,74 @@ export function MobileSwipeToReplyLayer({ room, editor, children }: MobileSwipeT 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; @@ -106,86 +178,26 @@ export function MobileSwipeToReplyLayer({ room, editor, children }: MobileSwipeT messageEl, messageId, moved: false, + offset: 0, }; - - layerRef.current?.setPointerCapture(evt.pointerId); }, [enabled] ); - const handlePointerMove = useCallback( - (evt: React.PointerEvent) => { - const drag = dragRef.current; - if (!drag || drag.pointerId !== evt.pointerId) return; + const isActivePointer = useCallback((pointerId: number) => { + return dragRef.current?.pointerId === pointerId; + }, []); - const deltaX = evt.clientX - drag.startX; - const deltaY = evt.clientY - drag.startY; + useWindowPointerDrag({ + enabled, + isActivePointer, + onMove: processPointerMove, + onEnd: endDrag, + }); - if (!drag.moved) { - if (Math.abs(deltaX) < 8 && Math.abs(deltaY) < 8) return; - if (Math.abs(deltaY) > Math.abs(deltaX)) { - resetGesture(); - return; - } - if (deltaX >= 0) { - resetGesture(); - return; - } - drag.moved = true; - } - - evt.preventDefault(); - const offset = Math.max(deltaX, -MAX_SWIPE); - drag.messageEl.style.transition = 'none'; - drag.messageEl.style.transform = `translateX(${offset}px)`; - updateIndicator(drag.messageEl, offset); - }, - [resetGesture, updateIndicator] - ); - - const endDrag = useCallback( - (pointerId: number) => { - const drag = dragRef.current; - if (!drag || drag.pointerId !== pointerId) return; - - if (layerRef.current?.hasPointerCapture(pointerId)) { - layerRef.current.releasePointerCapture(pointerId); - } - - const { messageEl, messageId, moved } = drag; - dragRef.current = null; - - if (!moved) { - resetGesture(); - return; - } - - const matrix = window.getComputedStyle(messageEl).transform; - const offset = - matrix && matrix !== 'none' - ? Number(new DOMMatrix(matrix).m41) - : 0; - - const shouldReply = Math.abs(offset) >= SWIPE_THRESHOLD; - clearMessageTransform(messageEl, true); - setIndicatorTop(null); - setIndicatorActive(false); - - if (shouldReply) { - startReplyToEvent(room, messageId, setReplyDraft, editor); - } - }, - [clearMessageTransform, editor, resetGesture, room, setReplyDraft] - ); - - const handlePointerUp = useCallback( - (evt: React.PointerEvent) => { - endDrag(evt.pointerId); - }, - [endDrag] - ); - - useEffect(() => resetGesture, [room.roomId, resetGesture]); + useEffect(() => { + resetGesture(); + }, [room.roomId, resetGesture]); if (!enabled) { return <>{children}; @@ -196,9 +208,10 @@ export function MobileSwipeToReplyLayer({ room, editor, children }: MobileSwipeT ref={layerRef} className={css.SwipeToReplyLayer} onPointerDown={handlePointerDown} - onPointerMove={handlePointerMove} - onPointerUp={handlePointerUp} - onPointerCancel={handlePointerUp} + onPointerMove={processPointerMove} + onPointerUp={(evt) => endDrag(evt.pointerId)} + onPointerCancel={(evt) => endDrag(evt.pointerId)} + onLostPointerCapture={(evt) => endDrag(evt.pointerId)} > {children} {indicatorTop !== null && ( diff --git a/overlay/src/app/components/mobile/mobile-gestures.css.ts b/overlay/src/app/components/mobile/mobile-gestures.css.ts index a5eb552..5d8535b 100644 --- a/overlay/src/app/components/mobile/mobile-gestures.css.ts +++ b/overlay/src/app/components/mobile/mobile-gestures.css.ts @@ -9,6 +9,7 @@ export const SwipeBackRoot = style({ overflow: 'hidden', display: 'flex', flexDirection: 'column', + touchAction: 'pan-y', }); export const SwipeBackUnderlay = style({ diff --git a/overlay/src/app/components/mobile/mobileGestureArbitration.ts b/overlay/src/app/components/mobile/mobileGestureArbitration.ts new file mode 100644 index 0000000..d60f6e8 --- /dev/null +++ b/overlay/src/app/components/mobile/mobileGestureArbitration.ts @@ -0,0 +1,27 @@ +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; + } +}; diff --git a/overlay/src/app/components/mobile/useWindowPointerDrag.ts b/overlay/src/app/components/mobile/useWindowPointerDrag.ts new file mode 100644 index 0000000..8130f8b --- /dev/null +++ b/overlay/src/app/components/mobile/useWindowPointerDrag.ts @@ -0,0 +1,46 @@ +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]); +};