chore: update cinny submodule to latest commit and modify versioning for Android app
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m15s
Build / create-release (push) Successful in 13s

This commit is contained in:
2026-07-09 17:42:28 +10:00
parent 8753ed2600
commit 83b4cbbd8e
6 changed files with 305 additions and 176 deletions

View File

@@ -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<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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)}
>
<div className={css.SwipeBackUnderlay} aria-hidden>
<div className={css.SwipeBackSidebarPeek} />

View File

@@ -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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 && (

View File

@@ -9,6 +9,7 @@ export const SwipeBackRoot = style({
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
touchAction: 'pan-y',
});
export const SwipeBackUnderlay = style({

View File

@@ -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;
}
};

View File

@@ -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]);
};