chore: update cinny submodule to latest commit for improved navigation and routing

This commit is contained in:
2026-07-09 17:23:45 +10:00
parent b496c70190
commit 82effc9680
6 changed files with 706 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { MobileSwipeBackPanel } from './mobile/MobileSwipeBackPanel';
/**
* Wrapper for Outlet that adds route-based animation
* Forces remount on route change by using location as key
*/
export function AnimatedOutlet() {
const location = useLocation();
return (
<MobileSwipeBackPanel>
<div
key={location.pathname}
data-route-transition="true"
style={{
flex: 1,
minWidth: 0,
minHeight: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Outlet />
</div>
</MobileSwipeBackPanel>
);
}

View File

@@ -0,0 +1,180 @@
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { useCompactNav } from '../../hooks/useCompactNav';
import { useBackRoute } from '../../hooks/useBackRoute';
import * as css from './mobile-gestures.css';
const COMMIT_RATIO = 0.28;
const MIN_COMMIT_PX = 72;
const MAX_START_Y_RATIO = 0.88;
type DragState = {
pointerId: number;
startX: number;
startY: number;
dragging: boolean;
moved: boolean;
};
type MobileSwipeBackPanelProps = {
children: ReactNode;
};
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 [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)';
}, []);
const setTransform = useCallback((px: number, animate = false) => {
const content = contentRef.current;
if (!content) return;
setOffset(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 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,
dragging: true,
moved: false,
};
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 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]
);
useEffect(() => {
resetTransform(false);
}, [enabled, resetTransform]);
if (!enabled) {
return <>{children}</>;
}
return (
<div
ref={rootRef}
className={css.SwipeBackRoot}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<div className={css.SwipeBackUnderlay} aria-hidden>
<div className={css.SwipeBackSidebarPeek} />
<div className={css.SwipeBackChannelPeek} />
</div>
<div ref={contentRef} className={css.SwipeBackContent}>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,217 @@
import React, { ReactNode, useCallback, useEffect, 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 * as css from './mobile-gestures.css';
const SWIPE_THRESHOLD = 56;
const MAX_SWIPE = 88;
type DragState = {
pointerId: number;
startX: number;
startY: number;
messageEl: HTMLElement;
messageId: string;
moved: boolean;
};
type MobileSwipeToReplyLayerProps = {
room: Room;
editor: Editor;
children: ReactNode;
};
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)';
}, []);
const resetGesture = useCallback(() => {
clearMessageTransform();
dragRef.current = null;
setIndicatorTop(null);
setIndicatorActive(false);
}, [clearMessageTransform]);
const shouldIgnoreTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof Element)) return true;
if (
target.closest(
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], button, a, [role="button"], [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 => {
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 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,
};
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 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)) {
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]);
if (!enabled) {
return <>{children}</>;
}
return (
<div
ref={layerRef}
className={css.SwipeToReplyLayer}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{children}
{indicatorTop !== null && (
<div
className={`${css.SwipeToReplyIndicator} ${
indicatorActive ? css.SwipeToReplyIndicatorActive : ''
}`}
style={{ top: indicatorTop }}
aria-hidden
>
<Icon src={Icons.ReplyArrow} size="200" />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,78 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
export const SwipeBackRoot = style({
position: 'relative',
flex: 1,
minWidth: 0,
minHeight: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
});
export const SwipeBackUnderlay = style({
position: 'absolute',
inset: 0,
display: 'flex',
pointerEvents: 'none',
zIndex: 0,
});
export const SwipeBackSidebarPeek = style({
width: toRem(66),
flexShrink: 0,
backgroundColor: color.Background.Container,
borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
});
export const SwipeBackChannelPeek = style({
flex: 1,
minWidth: 0,
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({
position: 'absolute',
right: config.space.S300,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: toRem(36),
height: toRem(36),
borderRadius: config.radii.Pill,
backgroundColor: color.Primary.Container,
color: color.Primary.OnContainer,
pointerEvents: 'none',
zIndex: 2,
opacity: 0,
transform: 'scale(0.85)',
transition: 'opacity 0.12s ease, transform 0.12s ease',
});
export const SwipeToReplyIndicatorActive = style({
opacity: 1,
transform: 'scale(1)',
});