From 82effc968018b260c65e67703a7859e1b9dd335f Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Thu, 9 Jul 2026 17:23:45 +1000 Subject: [PATCH] chore: update cinny submodule to latest commit for improved navigation and routing --- overlay/src/app/components/AnimatedOutlet.tsx | 30 +++ .../mobile/MobileSwipeBackPanel.tsx | 180 +++++++++++++++ .../mobile/MobileSwipeToReplyLayer.tsx | 217 ++++++++++++++++++ .../components/mobile/mobile-gestures.css.ts | 78 +++++++ overlay/src/app/features/room/RoomView.tsx | 160 +++++++++++++ .../src/app/features/room/replyToMessage.ts | 41 ++++ 6 files changed, 706 insertions(+) create mode 100644 overlay/src/app/components/AnimatedOutlet.tsx create mode 100644 overlay/src/app/components/mobile/MobileSwipeBackPanel.tsx create mode 100644 overlay/src/app/components/mobile/MobileSwipeToReplyLayer.tsx create mode 100644 overlay/src/app/components/mobile/mobile-gestures.css.ts create mode 100644 overlay/src/app/features/room/RoomView.tsx create mode 100644 overlay/src/app/features/room/replyToMessage.ts diff --git a/overlay/src/app/components/AnimatedOutlet.tsx b/overlay/src/app/components/AnimatedOutlet.tsx new file mode 100644 index 0000000..9ec9cf3 --- /dev/null +++ b/overlay/src/app/components/AnimatedOutlet.tsx @@ -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 ( + +
+ +
+
+ ); +} diff --git a/overlay/src/app/components/mobile/MobileSwipeBackPanel.tsx b/overlay/src/app/components/mobile/MobileSwipeBackPanel.tsx new file mode 100644 index 0000000..6b5be49 --- /dev/null +++ b/overlay/src/app/components/mobile/MobileSwipeBackPanel.tsx @@ -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(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)'; + }, []); + + 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) => { + 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) => { + 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) => { + endDrag(evt.pointerId); + }, + [endDrag] + ); + + useEffect(() => { + resetTransform(false); + }, [enabled, resetTransform]); + + if (!enabled) { + return <>{children}; + } + + return ( +
+
+
+
+
+
+ {children} +
+
+ ); +} diff --git a/overlay/src/app/components/mobile/MobileSwipeToReplyLayer.tsx b/overlay/src/app/components/mobile/MobileSwipeToReplyLayer.tsx new file mode 100644 index 0000000..cfaacdf --- /dev/null +++ b/overlay/src/app/components/mobile/MobileSwipeToReplyLayer.tsx @@ -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(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)'; + }, []); + + 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) => { + 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) => { + 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) => { + endDrag(evt.pointerId); + }, + [endDrag] + ); + + useEffect(() => resetGesture, [room.roomId, resetGesture]); + + if (!enabled) { + return <>{children}; + } + + return ( +
+ {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 new file mode 100644 index 0000000..a5eb552 --- /dev/null +++ b/overlay/src/app/components/mobile/mobile-gestures.css.ts @@ -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)', +}); diff --git a/overlay/src/app/features/room/RoomView.tsx b/overlay/src/app/features/room/RoomView.tsx new file mode 100644 index 0000000..47e478d --- /dev/null +++ b/overlay/src/app/features/room/RoomView.tsx @@ -0,0 +1,160 @@ +import React, { useCallback, useRef } from 'react'; +import { Box, Text, config } from 'folds'; +import { EventType, Room } from 'matrix-js-sdk'; +import { ReactEditor } from 'slate-react'; +import { isKeyHotkey } from 'is-hotkey'; +import { useAtomValue } from 'jotai'; +import { useStateEvent } from '../../hooks/useStateEvent'; +import { StateEvent } from '../../../types/matrix/room'; +import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useEditor } from '../../components/editor'; +import { RoomInputPlaceholder } from './RoomInputPlaceholder'; +import { RoomTimeline } from './RoomTimeline'; +import { RoomViewTyping } from './RoomViewTyping'; +import { RoomTombstone } from './RoomTombstone'; +import { RoomInput } from './RoomInput'; +import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing'; +import { Page } from '../../components/page'; +import { RoomViewHeader } from './RoomViewHeader'; +import { useKeyDown } from '../../hooks/useKeyDown'; +import { editableActiveElement } from '../../utils/dom'; +import { settingsAtom } from '../../state/settings'; +import { useSetting } from '../../state/hooks/settings'; +import { useRoomPermissions } from '../../hooks/useRoomPermissions'; +import { useRoomCreators } from '../../hooks/useRoomCreators'; +import { activeThreadIdAtomFamily } from '../../state/activeThread'; +import { ThreadView } from './ThreadView'; +import { MobileSwipeToReplyLayer } from '../../components/mobile/MobileSwipeToReplyLayer'; + +const FN_KEYS_REGEX = /^F\d+$/; +const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { + const { code } = evt; + if (evt.metaKey || evt.altKey || evt.ctrlKey) { + return false; + } + + if (FN_KEYS_REGEX.test(code)) return false; + + if ( + code.startsWith('OS') || + code.startsWith('Meta') || + code.startsWith('Shift') || + code.startsWith('Alt') || + code.startsWith('Control') || + code.startsWith('Arrow') || + code.startsWith('Page') || + code.startsWith('End') || + code.startsWith('Home') || + code === 'Tab' || + code === 'Space' || + code === 'Enter' || + code === 'NumLock' || + code === 'ScrollLock' + ) { + return false; + } + + return true; +}; + +export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { + const roomInputRef = useRef(null); + const roomViewRef = useRef(null); + + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + + const { roomId } = room; + const editor = useEditor(); + + const mx = useMatrixClient(); + + const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone); + const powerLevels = usePowerLevelsContext(); + const creators = useRoomCreators(room); + + const permissions = useRoomPermissions(creators, powerLevels); + const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId()); + + const activeThreadId = useAtomValue(activeThreadIdAtomFamily(roomId)); + + useKeyDown( + window, + useCallback( + (evt) => { + if (editableActiveElement()) return; + const portalContainer = document.getElementById('portalContainer'); + if (portalContainer && portalContainer.children.length > 0) { + return; + } + if (shouldFocusMessageField(evt)) { + evt.preventDefault(); + ReactEditor.focus(editor); + if (evt.key.length === 1) { + editor.insertText(evt.key); + } + } else if (isKeyHotkey('mod+v', evt)) { + ReactEditor.focus(editor); + } + }, + [editor] + ) + ); + + return ( + + + {activeThreadId ? ( + + ) : ( + <> + + + + + + + +
+ {tombstoneEvent ? ( + + ) : ( + <> + {canMessage && ( + + )} + {!canMessage && ( + + You do not have permission to post in this room + + )} + + )} +
+ {hideActivity ? : } +
+ + )} +
+ ); +} diff --git a/overlay/src/app/features/room/replyToMessage.ts b/overlay/src/app/features/room/replyToMessage.ts new file mode 100644 index 0000000..4ec300b --- /dev/null +++ b/overlay/src/app/features/room/replyToMessage.ts @@ -0,0 +1,41 @@ +import { RelationType, Room } from 'matrix-js-sdk'; +import { Editor } from 'slate'; +import { ReactEditor } from 'slate-react'; +import { getEditedEvent } from '../../utils/room'; +import { IReplyDraft } from '../../state/room/roomInputDrafts'; + +export const startReplyToEvent = ( + room: Room, + eventId: string, + setReplyDraft: (draft: IReplyDraft | undefined) => void, + editor?: Editor, + startThread = false +): boolean => { + const replyEvt = room.findEventById(eventId); + if (!replyEvt || replyEvt.isRedacted()) return false; + + const editedReply = getEditedEvent(eventId, replyEvt, room.getUnfilteredTimelineSet()); + const content = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent(); + const { body, formatted_body: formattedBody } = content; + const senderId = replyEvt.getSender(); + + if (!senderId || typeof body !== 'string') return false; + + const relation = startThread + ? { rel_type: RelationType.Thread, event_id: eventId } + : replyEvt.getWireContent()['m.relates_to']; + + setReplyDraft({ + userId: senderId, + eventId, + body, + formattedBody, + relation, + }); + + if (editor) { + setTimeout(() => ReactEditor.focus(editor), 100); + } + + return true; +};