From 67b891541c9f4a9600931026746a19b43ba5d75f Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 20 Jul 2026 00:16:57 +0200 Subject: [PATCH] perf(mobile): overlapping-panels swipe, immediate room open, progressive decryption --- .../fix-overlapping-panels-mobile-swipe.md | 5 + src/app/components/BackRouteHandler.tsx | 9 +- src/app/components/page/MobileNavDrawer.tsx | 232 ++++++++---------- .../components/page/PersistentRoomHost.tsx | 87 +++++++ src/app/features/room-nav/RoomNavItem.tsx | 2 + src/app/features/room/Room.tsx | 6 +- src/app/features/room/RoomTimeline.tsx | 5 +- src/app/features/room/ThreadDrawer.tsx | 5 +- .../room/message/EncryptedContent.tsx | 25 +- src/app/generated/tauri/commands.ts | 2 +- src/app/generated/tauri/index.ts | 2 +- src/app/generated/tauri/types.ts | 2 +- src/app/hooks/useRoom.ts | 13 + src/app/hooks/useRoomNavigate.ts | 38 +-- src/app/pages/client/ClientNonUIFeatures.tsx | 8 +- src/app/pages/client/direct/RoomProvider.tsx | 25 +- src/app/pages/client/home/RoomProvider.tsx | 25 +- src/app/pages/client/space/RoomProvider.tsx | 29 ++- src/app/state/room/lastRoom.ts | 23 +- src/app/state/utils/atomWithLocalStorage.ts | 8 +- src/app/utils/decryptScheduler.ts | 46 ++++ 21 files changed, 384 insertions(+), 213 deletions(-) create mode 100644 .changeset/fix-overlapping-panels-mobile-swipe.md create mode 100644 src/app/components/page/PersistentRoomHost.tsx create mode 100644 src/app/utils/decryptScheduler.ts diff --git a/.changeset/fix-overlapping-panels-mobile-swipe.md b/.changeset/fix-overlapping-panels-mobile-swipe.md new file mode 100644 index 0000000000..2c2ad21691 --- /dev/null +++ b/.changeset/fix-overlapping-panels-mobile-swipe.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Smoother mobile navigation and faster room opening. diff --git a/src/app/components/BackRouteHandler.tsx b/src/app/components/BackRouteHandler.tsx index 69ceb097d0..65ff218c57 100644 --- a/src/app/components/BackRouteHandler.tsx +++ b/src/app/components/BackRouteHandler.tsx @@ -5,6 +5,7 @@ import { matchPath, useLocation, useNavigate } from 'react-router-dom'; import { resolveSection } from '$pages/pathUtils'; import { HOME_ROOM_PATH, DIRECT_ROOM_PATH, SPACE_ROOM_PATH } from '$pages/paths'; import { lastVisitedRoomAtom } from '$state/room/lastRoom'; +import { isRoomAlias, isRoomId } from '$utils/matrix'; type BackRouteHandlerProps = { children: (onBack: () => void) => ReactNode; @@ -24,11 +25,9 @@ export function BackRouteHandler({ children }: BackRouteHandlerProps) { .find((match) => match !== null); const currentRoomIdOrAlias = roomMatch?.params.roomIdOrAlias; - if (section.getRoomPath && currentRoomIdOrAlias) { - setLastRoom({ - section: section.key, - roomId: decodeURIComponent(currentRoomIdOrAlias), - }); + const decoded = currentRoomIdOrAlias && decodeURIComponent(currentRoomIdOrAlias); + if (section.getRoomPath && decoded && (isRoomId(decoded) || isRoomAlias(decoded))) { + setLastRoom((prev) => ({ ...prev, [section.key]: decoded })); } navigate(section.listPath); diff --git a/src/app/components/page/MobileNavDrawer.tsx b/src/app/components/page/MobileNavDrawer.tsx index 96f4647d88..39ba12a453 100644 --- a/src/app/components/page/MobileNavDrawer.tsx +++ b/src/app/components/page/MobileNavDrawer.tsx @@ -4,7 +4,6 @@ import { motion, useMotionValue, useReducedMotion } from 'framer-motion'; import { useDrag } from '@use-gesture/react'; import { useAtomValue, useSetAtom } from 'jotai'; import { matchPath, useLocation, useNavigate } from 'react-router-dom'; -import { useMatrixClient } from '$hooks/useMatrixClient'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { lastVisitedRoomAtom } from '$state/room/lastRoom'; @@ -19,6 +18,8 @@ import { SPACE_ROOM_PATH, } from '$pages/paths'; import { resolveSection } from '$pages/pathUtils'; +import { isRoomAlias, isRoomId } from '$utils/matrix'; +import { PersistentRoomHost } from './PersistentRoomHost'; const SLIDE_MS = 300; const SLIDE_EASE = 'cubic-bezier(0.32, 0.72, 0, 1)'; @@ -41,18 +42,27 @@ const clamp = (value: number, min: number, max: number): number => export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDrawerProps) { const [mobileGestures] = useSetting(settingsAtom, 'mobileGestures'); const reduceMotion = useReducedMotion(); - const matrixClient = useMatrixClient(); const location = useLocation(); const navigate = useNavigate(); const setLastRoom = useSetAtom(lastVisitedRoomAtom); const lastRoom = useAtomValue(lastVisitedRoomAtom); + const openableSection = resolveSection(location.pathname); + const canOpenRoom = Boolean( + openableSection && openableSection.getRoomPath && lastRoom?.[openableSection.key] + ); + const roomMatch = matchPath({ path: HOME_ROOM_PATH, end: false }, location.pathname) ?? matchPath({ path: DIRECT_ROOM_PATH, end: false }, location.pathname) ?? matchPath({ path: SPACE_ROOM_PATH, end: false }, location.pathname); + const matchedRoomId = roomMatch?.params.roomIdOrAlias + ? decodeURIComponent(roomMatch.params.roomIdOrAlias) + : undefined; + // `:roomIdOrAlias` also matches non-room segments like `create`, `search`, `lobby`. + // Only treat it as a room when it's a real Matrix id/alias. + const isRoomRoute = !!matchedRoomId && (isRoomId(matchedRoomId) || isRoomAlias(matchedRoomId)); - // The bare section route is the list; anything deeper is content revealed by dragging. const listView = matchPath({ path: HOME_PATH, end: true }, location.pathname) !== null || matchPath({ path: DIRECT_PATH, end: true }, location.pathname) !== null || @@ -68,18 +78,24 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra const [width, setWidth] = useState(0); const x = useMotionValue(0); const draggingRef = useRef(false); - const openingRef = useRef(false); - const prevContentOpenRef = useRef(contentOpen); - const openRafRef = useRef(0); - const [deferContent, setDeferContent] = useState(false); - const [prevOpen, setPrevOpen] = useState(contentOpen); - if (contentOpen !== prevOpen) { - setPrevOpen(contentOpen); - setDeferContent(contentOpen && !reduceMotion && !draggingRef.current); - } + const initialIntent = contentOpen ? 1 : 0; + const [panelIntent, setPanelIntent] = useState(initialIntent); + const panelIntentRef = useRef(initialIntent); + + const [roomArmed, setRoomArmed] = useState(isRoomRoute); + useEffect(() => { + if (isRoomRoute) { + setRoomArmed(true); + return undefined; + } + if (roomArmed) return undefined; + const ric = window.requestIdleCallback ?? ((cb: () => void) => window.setTimeout(cb, 200)); + const cic = window.cancelIdleCallback ?? window.clearTimeout; + const handle = ric(() => setRoomArmed(true)); + return () => cic(handle as number); + }, [isRoomRoute, roomArmed]); - // Compositor transition for the settle animation; off = finger-1:1 during drag. const applyTransition = useCallback((animated: boolean) => { const el = sliderRef.current; if (el) el.style.transition = animated ? `transform ${SLIDE_MS}ms ${SLIDE_EASE}` : 'none'; @@ -98,15 +114,17 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra [reduceMotion, x, applyTransition] ); - // Release the deferred room after the slide is committed to the compositor. - useLayoutEffect(() => { - if (!deferContent) return undefined; - cancelAnimationFrame(openRafRef.current); - openRafRef.current = requestAnimationFrame(() => - requestAnimationFrame(() => setDeferContent(false)) - ); - return () => cancelAnimationFrame(openRafRef.current); - }, [deferContent]); + const readX = useCallback((): number => { + const el = sliderRef.current; + if (!el) return x.get(); + const t = getComputedStyle(el).transform; + if (!t || t === 'none') return 0; + try { + return new DOMMatrix(t).m41; + } catch { + return x.get(); + } + }, [x]); useLayoutEffect(() => { const el = viewportRef.current; @@ -118,88 +136,33 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra return () => observer.disconnect(); }, []); - // Keep the offscreen panel out of the focus order and accessibility tree. useLayoutEffect(() => { - navPanelRef.current?.toggleAttribute('inert', contentOpen); - contentPanelRef.current?.toggleAttribute('inert', !contentOpen); - }, [contentOpen]); + navPanelRef.current?.toggleAttribute('inert', panelIntent === 1); + contentPanelRef.current?.toggleAttribute('inert', panelIntent === 0); + }, [panelIntent]); - // Sync panel position to the route: animate on route change, jump on mount/resize. useLayoutEffect(() => { - const routeChanged = prevContentOpenRef.current !== contentOpen; - prevContentOpenRef.current = contentOpen; if (draggingRef.current) return; - const target = contentOpen ? -width : 0; - if (routeChanged && width > 0 && !reduceMotion) { - settle(target); - } else { - applyTransition(false); - x.jump(target); + const routePanel = contentOpen ? 1 : 0; + if (routePanel !== panelIntentRef.current) { + panelIntentRef.current = routePanel; + setPanelIntent(routePanel); + const target = routePanel === 1 ? -width : 0; + if (width > 0 && !reduceMotion) { + settle(target); + } else { + applyTransition(false); + x.jump(target); + } } }, [contentOpen, width, x, settle, applyTransition, reduceMotion]); - const goToList = useCallback((): boolean => { - const section = resolveSection(location.pathname); - if (!section) return false; - - const id = roomMatch?.params.roomIdOrAlias; - if (section.getRoomPath && id) { - setLastRoom({ section: section.key, roomId: decodeURIComponent(id) }); - } - - navigate(section.listPath); - return true; - }, [roomMatch, location.pathname, navigate, setLastRoom]); - - const goToRoom = useCallback((): boolean => { - const section = resolveSection(location.pathname); - if (!section?.getRoomPath) return false; - // Scope the remembered room to its section so a DM never opens under /home/, etc. - if (!lastRoom || lastRoom.section !== section.key) return false; - - startTransition(() => navigate(section.getRoomPath!(lastRoom.roomId))); - return true; - }, [lastRoom, location.pathname, navigate]); - - const openableSection = resolveSection(location.pathname); - const canOpenRoom = - !!openableSection?.getRoomPath && !!lastRoom && lastRoom.section === openableSection.key; - - useEffect(() => { - if (contentOpen || !canOpenRoom || !lastRoom) return undefined; - const room = matrixClient.getRoom(lastRoom.roomId); - if (!room) return undefined; - const ric = - window.requestIdleCallback ?? - ((cb: IdleRequestCallback) => - window.setTimeout( - () => cb({ didTimeout: false, timeRemaining: () => 30 } as IdleDeadline), - 200 - )); - const cic = window.cancelIdleCallback ?? window.clearTimeout; - const handle = ric(() => { - room - .getLiveTimeline() - .getEvents() - .slice(-20) - .forEach((event) => { - if (event.isEncrypted()) matrixClient.decryptEventIfNeeded(event).catch(() => undefined); - }); - }); - return () => cic(handle); - }, [contentOpen, canOpenRoom, lastRoom, matrixClient]); - - const readX = useCallback((): number => { - const el = sliderRef.current; - if (!el) return x.get(); - const t = getComputedStyle(el).transform; - if (!t || t === 'none') return 0; - try { - return new DOMMatrix(t).m41; - } catch { - return x.get(); - } - }, [x]); + useLayoutEffect(() => { + if (draggingRef.current) return; + const target = panelIntentRef.current === 1 ? -width : 0; + applyTransition(false); + x.jump(target); + }, [width, x, applyTransition]); const bind = useDrag( ({ @@ -216,8 +179,7 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra if (canceled) { if (draggingRef.current) { draggingRef.current = false; - openingRef.current = false; - settle(contentOpen ? -width : 0); + settle(panelIntentRef.current === 1 ? -width : 0); } return; } @@ -229,8 +191,8 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra return; } - if (contentOpen) { - if (mx < -DIRECTION_DEADZONE && !openingRef.current) { + if (panelIntentRef.current === 1) { + if (mx < -DIRECTION_DEADZONE) { if (draggingRef.current) { draggingRef.current = false; settle(-width); @@ -239,7 +201,6 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra return; } if (active) { - // Take over any settling animation; offset is seeded from the live position. if (first) { x.stop(); applyTransition(false); @@ -249,20 +210,16 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra return; } draggingRef.current = false; - if (openingRef.current) { - openingRef.current = false; - const keepRoom = -ox > width * OPEN_FRACTION || (vx > VELOCITY_THRESHOLD && dx < 0); - if (keepRoom) { - settle(-width); - } else { - goToList(); - settle(0); - } - return; - } const opened = width + ox > width * OPEN_FRACTION || (vx > VELOCITY_THRESHOLD && dx > 0); - if (opened && goToList()) { + if (opened) { + panelIntentRef.current = 0; + setPanelIntent(0); settle(0); + const section = resolveSection(location.pathname); + if (section?.getRoomPath && matchedRoomId && isRoomRoute) { + setLastRoom((prev) => ({ ...prev, [section.key]: matchedRoomId })); + } + if (section) startTransition(() => navigate(section.listPath)); } else { settle(-width); } @@ -270,10 +227,6 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra } if (mx > DIRECTION_DEADZONE) { - if (draggingRef.current) { - draggingRef.current = false; - settle(0); - } cancel(); return; } @@ -285,22 +238,28 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra if (first) { x.stop(); applyTransition(false); + if (!roomArmed) setRoomArmed(true); } draggingRef.current = true; x.set(clamp(ox, -width, 0)); - if (!openingRef.current && -ox > width * 0.12 && goToRoom()) { - openingRef.current = true; - } return; } draggingRef.current = false; - if (openingRef.current) { - openingRef.current = false; - const keep = -ox > width * OPEN_FRACTION || (vx > VELOCITY_THRESHOLD && dx < 0); - if (keep) { - settle(-width); + const wantRoom = -ox > width * OPEN_FRACTION || (vx > VELOCITY_THRESHOLD && dx < 0); + if (wantRoom) { + const section = resolveSection(location.pathname); + if (section?.getRoomPath) { + const lastRoomId = lastRoom?.[section.key]; + if (lastRoomId) { + const roomPath = section.getRoomPath(lastRoomId); + panelIntentRef.current = 1; + setPanelIntent(1); + settle(-width); + startTransition(() => navigate(roomPath)); + } else { + settle(0); + } } else { - goToList(); settle(0); } } else { @@ -358,14 +317,7 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra {rail && (
{rail}
)} -
+
{nav}
@@ -381,7 +333,15 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra overflow: 'hidden', }} > - {deferContent ? null : children} + {isRoomRoute ? ( + + ) : listView ? ( + roomArmed ? ( + + ) : null + ) : ( + children + )} diff --git a/src/app/components/page/PersistentRoomHost.tsx b/src/app/components/page/PersistentRoomHost.tsx new file mode 100644 index 0000000000..dd5fc35cf2 --- /dev/null +++ b/src/app/components/page/PersistentRoomHost.tsx @@ -0,0 +1,87 @@ +import type { ReactNode } from 'react'; +import { matchPath, useLocation } from 'react-router-dom'; +import { useAtomValue } from 'jotai'; +import { Room } from '$features/room'; +import { IsInactivePanelProvider } from '$hooks/useRoom'; +import { HomeRouteRoomProvider } from '$pages/client/home'; +import { DirectRouteRoomProvider } from '$pages/client/direct'; +import { SpaceRouteRoomProvider } from '$pages/client/space'; +import { lastVisitedRoomAtom } from '$state/room/lastRoom'; +import { resolveSection, type SectionNav } from '$pages/pathUtils'; +import { HOME_ROOM_PATH, DIRECT_ROOM_PATH, SPACE_ROOM_PATH } from '$pages/paths'; +import { isRoomAlias, isRoomId } from '$utils/matrix'; + +export type DisplayedRoom = { + roomIdOrAlias: string; + eventId?: string; +}; + +export function useDisplayedRoom(section: SectionNav | null): DisplayedRoom | undefined { + const location = useLocation(); + const lastRoom = useAtomValue(lastVisitedRoomAtom); + + if (!section || !section.getRoomPath) return undefined; + + const roomMatch = + matchPath({ path: HOME_ROOM_PATH, end: false }, location.pathname) ?? + matchPath({ path: DIRECT_ROOM_PATH, end: false }, location.pathname) ?? + matchPath({ path: SPACE_ROOM_PATH, end: false }, location.pathname); + + if (roomMatch) { + const encodedId = roomMatch.params.roomIdOrAlias; + const encodedEvent = roomMatch.params.eventId; + if (encodedId) { + const decodedId = decodeURIComponent(encodedId); + // `:roomIdOrAlias` also matches non-room segments like `create`, `search`, `lobby`. + // Only treat it as a room when it's a real Matrix id/alias. + if (isRoomId(decodedId) || isRoomAlias(decodedId)) { + return { + roomIdOrAlias: decodedId, + eventId: encodedEvent ? decodeURIComponent(encodedEvent) : undefined, + }; + } + } + } + + const lastRoomId = lastRoom?.[section.key]; + if (lastRoomId) { + return { roomIdOrAlias: lastRoomId }; + } + + return undefined; +} + +export function PersistentRoomHost({ inactive }: { inactive: boolean }) { + const location = useLocation(); + const section = resolveSection(location.pathname); + const displayed = useDisplayedRoom(section); + + if (!displayed) return null; + + const roomNode = ; + + let hosted: ReactNode = null; + if (section?.key === 'home') { + hosted = ( + + {roomNode} + + ); + } else if (section?.key === 'direct') { + hosted = ( + + {roomNode} + + ); + } else if (section?.key.startsWith('space:')) { + hosted = ( + + {roomNode} + + ); + } + + if (!hosted) return null; + + return {hosted}; +} diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 68e6c9882a..67e20547c7 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -72,6 +72,7 @@ import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { useRoomName, useRoomTopic } from '$hooks/useRoomMeta'; import { nicknamesAtom } from '$state/nicknames'; import { useRoomNavigate } from '$hooks/useRoomNavigate'; +import { warmupRoomDecryption } from '$utils/decryptScheduler'; // Call Hooks & Plugins import { useCallMembers, useCallSession } from '$hooks/useCall'; @@ -425,6 +426,7 @@ export function RoomNavItem({ {(triggerRef) => ( warmupRoomDecryption(mx, room.roomId)} aria-label={ariaLabel} ref={triggerRef} style={hideTextStyling(hideText)} diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index fa7d171cb6..d46c414b71 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -7,7 +7,7 @@ import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { PowerLevelsContextProvider, usePowerLevels } from '$hooks/usePowerLevels'; -import { useRoom } from '$hooks/useRoom'; +import { useRoom, useDisplayedEventId } from '$hooks/useRoom'; import { useKeyDown } from '$hooks/useKeyDown'; import { markAsRead } from '$utils/notifications'; import { useMatrixClient } from '$hooks/useMatrixClient'; @@ -30,7 +30,9 @@ import { ThreadBrowser } from './ThreadBrowser'; const debugLog = createDebugLogger('Room'); export function Room() { - const { eventId } = useParams(); + const displayedEventId = useDisplayedEventId(); + const { eventId: paramEventId } = useParams(); + const eventId = displayedEventId ?? paramEventId; const room = useRoom(); const mx = useMatrixClient(); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 29f471230e..4cd5f24442 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -25,6 +25,7 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { useAlive } from '$hooks/useAlive'; import { useMessageEdit } from '$hooks/useMessageEdit'; import { useDocumentFocusChange } from '$hooks/useDocumentFocusChange'; +import { useIsInactivePanel } from '$hooks/useRoom'; import { markAsRead } from '$utils/notifications'; import { getReactCustomHtmlParser, @@ -287,6 +288,7 @@ export function RoomTimeline({ const { editId, handleEdit } = useMessageEdit(editor, { onReset: onEditorReset, alive }); const { navigateRoom } = useRoomNavigate(); + const isInactivePanel = useIsInactivePanel(); const [hideReads] = useSetting(settingsAtom, 'hideReads'); const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); @@ -823,6 +825,7 @@ export function RoomTimeline({ }); const tryAutoMarkAsRead = useCallback(() => { + if (isInactivePanel) return; // Don't clear unread while room is behind the list if (!readUptoEventIdRef.current) { requestAnimationFrame(() => markAsRead(mx, room.roomId, hideReads)); return; @@ -832,7 +835,7 @@ export function RoomTimeline({ if (latestTimeline === room.getLiveTimeline()) { requestAnimationFrame(() => markAsRead(mx, room.roomId, hideReads)); } - }, [mx, room, hideReads]); + }, [mx, room, hideReads, isInactivePanel]); useDocumentFocusChange( useCallback( diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index fa9c3dc72a..d8d12a3cbd 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -37,6 +37,7 @@ import { } from '$utils/room'; import { getMxIdLocalPart, toggleReaction } from '$utils/matrix'; import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useIsInactivePanel } from '$hooks/useRoom'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { nicknamesAtom } from '$state/nicknames'; @@ -137,6 +138,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra const mentionClickHandler = useMentionClickHandler(room.roomId); const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); const spoilerClickHandler = useSpoilerClickHandler(); + const isInactivePanel = useIsInactivePanel(); // Settings const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); @@ -460,6 +462,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra // Mark thread as read when viewing it useEffect(() => { + if (isInactivePanel) return; // Don't send read receipt while room is behind the list const markThreadAsRead = async () => { const currentThread = room.getThread(threadRootId); if (!currentThread) return; @@ -488,7 +491,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra // Mark as read when opened and when new messages arrive markThreadAsRead(); - }, [mx, room, threadRootId, forceUpdateCounter]); + }, [mx, room, threadRootId, forceUpdateCounter, isInactivePanel]); const replyEvents = getThreadReplyEvents(room, threadRootId); const isThreadLoading = !!thread && !thread.initialEventsFetched && replyEvents.length === 0; diff --git a/src/app/features/room/message/EncryptedContent.tsx b/src/app/features/room/message/EncryptedContent.tsx index 2bb9273973..2ac498318f 100644 --- a/src/app/features/room/message/EncryptedContent.tsx +++ b/src/app/features/room/message/EncryptedContent.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from 'react'; import { useEffect, useState } from 'react'; import { useMatrixClient } from '$hooks/useMatrixClient'; +import { scheduleDecrypt } from '$utils/decryptScheduler'; import * as Sentry from '@sentry/react'; type EncryptedContentProps = { @@ -19,17 +20,19 @@ export function EncryptedContent({ mEvent, children }: EncryptedContentProps) { useEffect(() => { if (mEvent.getType() !== (EventType.RoomMessageEncrypted as string)) return; - // Sample 5% of events for per-event decryption latency profiling - if (Math.random() < 0.05) { - const start = performance.now(); - Sentry.startSpan({ name: 'decrypt.event', op: 'matrix.crypto' }, () => - mx.decryptEventIfNeeded(mEvent).then(() => { - Sentry.metrics.distribution('sable.decryption.event_ms', performance.now() - start); - }) - ).catch(() => undefined); - } else { - mx.decryptEventIfNeeded(mEvent).catch(() => undefined); - } + scheduleDecrypt(() => { + // Sample 5% of events for per-event decryption latency profiling + if (Math.random() < 0.05) { + const start = performance.now(); + Sentry.startSpan({ name: 'decrypt.event', op: 'matrix.crypto' }, () => + mx.decryptEventIfNeeded(mEvent).then(() => { + Sentry.metrics.distribution('sable.decryption.event_ms', performance.now() - start); + }) + ).catch(() => undefined); + } else { + mx.decryptEventIfNeeded(mEvent).catch(() => undefined); + } + }); }, [mx, mEvent]); useEffect(() => { diff --git a/src/app/generated/tauri/commands.ts b/src/app/generated/tauri/commands.ts index e64ac4964a..58feb867df 100644 --- a/src/app/generated/tauri/commands.ts +++ b/src/app/generated/tauri/commands.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-07-19T19:29:48.202533519+00:00 + * Generated at: 2026-07-20T05:55:07.154868857+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate diff --git a/src/app/generated/tauri/index.ts b/src/app/generated/tauri/index.ts index 79bba58a16..5fed3c34d5 100644 --- a/src/app/generated/tauri/index.ts +++ b/src/app/generated/tauri/index.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-07-19T19:29:48.202975583+00:00 + * Generated at: 2026-07-20T05:55:07.155133577+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate diff --git a/src/app/generated/tauri/types.ts b/src/app/generated/tauri/types.ts index 8755d0f32e..c6a8ffdb2d 100644 --- a/src/app/generated/tauri/types.ts +++ b/src/app/generated/tauri/types.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-07-19T19:29:48.201956318+00:00 + * Generated at: 2026-07-20T05:55:07.154428637+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate diff --git a/src/app/hooks/useRoom.ts b/src/app/hooks/useRoom.ts index 9617414e96..7d2f0259d7 100644 --- a/src/app/hooks/useRoom.ts +++ b/src/app/hooks/useRoom.ts @@ -24,3 +24,16 @@ export const useIsDirectRoom = () => { return direct; }; + +const DisplayedEventIdContext = createContext(undefined); + +export const DisplayedEventIdProvider = DisplayedEventIdContext.Provider; + +export const useDisplayedEventId = () => useContext(DisplayedEventIdContext); + +const IsInactivePanelContext = createContext(false); + +export const IsInactivePanelProvider = IsInactivePanelContext.Provider; + +/** True when the room is mounted behind the list panel. Gates auto-mark-as-read and read receipts. */ +export const useIsInactivePanel = () => useContext(IsInactivePanelContext); diff --git a/src/app/hooks/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts index e3f274d8ee..45945ca4f4 100644 --- a/src/app/hooks/useRoomNavigate.ts +++ b/src/app/hooks/useRoomNavigate.ts @@ -1,17 +1,19 @@ -import { startTransition, useCallback } from 'react'; +import { useCallback } from 'react'; import type { NavigateOptions } from 'react-router-dom'; import { useNavigate } from 'react-router-dom'; -import { useAtomValue } from 'jotai'; +import { useAtomValue, useSetAtom } from 'jotai'; import { getCanonicalAliasOrRoomId } from '$utils/matrix'; import { getDirectRoomPath, getHomeRoomPath, getSpacePath, getSpaceRoomPath, + resolveSection, } from '$pages/pathUtils'; import { getOrphanParents, guessPerfectParent } from '$utils/room'; import { roomToParentsAtom } from '$state/room/roomToParents'; import { mDirectAtom } from '$state/mDirectList'; +import { lastVisitedRoomAtom } from '$state/room/lastRoom'; import { settingsAtom } from '$state/settings'; import { useSetting } from '$state/hooks/settings'; import { useSelectedSpace } from './router/useSelectedSpace'; @@ -24,12 +26,12 @@ export const useRoomNavigate = () => { const mDirects = useAtomValue(mDirectAtom); const spaceSelectedId = useSelectedSpace(); const [developerTools] = useSetting(settingsAtom, 'developerTools'); + const setLastRoom = useSetAtom(lastVisitedRoomAtom); const navigateSpace = useCallback( (roomId: string) => { const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId); - // Render the (heavy) destination off the urgent path so the tap doesn't freeze the UI. - startTransition(() => navigate(getSpacePath(roomIdOrAlias))); + navigate(getSpacePath(roomIdOrAlias)); }, [mx, navigate] ); @@ -40,6 +42,8 @@ export const useRoomNavigate = () => { const openSpaceTimeline = developerTools && spaceSelectedId === roomId; const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId); + let destPath: string; + let roomPart: string; if (orphanParents.length > 0) { let parentSpace: string; if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) { @@ -49,24 +53,24 @@ export const useRoomNavigate = () => { } const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace); - - startTransition(() => - navigate( - getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId), - opts - ) - ); - return; + roomPart = openSpaceTimeline ? roomId : roomIdOrAlias; + destPath = getSpaceRoomPath(pSpaceIdOrAlias, roomPart, eventId); + } else if (mDirects.has(roomId)) { + roomPart = roomIdOrAlias; + destPath = getDirectRoomPath(roomPart, eventId); + } else { + roomPart = roomIdOrAlias; + destPath = getHomeRoomPath(roomPart, eventId); } - if (mDirects.has(roomId)) { - startTransition(() => navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts)); - return; + const section = resolveSection(destPath); + if (section?.getRoomPath) { + setLastRoom((prev) => ({ ...prev, [section.key]: roomPart })); } - startTransition(() => navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts)); + navigate(destPath, opts); }, - [mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools] + [mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools, setLastRoom] ); return { diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 5e949aee64..1051b4dcf5 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -4,7 +4,8 @@ import { type as osType } from '@tauri-apps/plugin-os'; import { setTrayBadge } from '$generated/tauri/commands'; import type { ReactNode } from 'react'; import { useCallback, useEffect, useRef } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { resolveSection } from '$pages/pathUtils'; import type { RoomEventHandlerMap } from '$types/matrix-sdk'; import { getPresenceSyncManager } from '$client/initMatrix'; import { @@ -808,8 +809,11 @@ function SlidingSyncActiveRoomSubscriber() { */ function SentryRoomContextFeature() { const mx = useMatrixClient(); + const location = useLocation(); const mDirect = useAtomValue(mDirectAtom); - const roomId = useAtomValue(lastVisitedRoomAtom)?.roomId; + const lastRoom = useAtomValue(lastVisitedRoomAtom); + const section = resolveSection(location.pathname); + const roomId = section ? lastRoom?.[section.key] : undefined; useEffect(() => { if (!roomId) { diff --git a/src/app/pages/client/direct/RoomProvider.tsx b/src/app/pages/client/direct/RoomProvider.tsx index 14c1622203..be791c7151 100644 --- a/src/app/pages/client/direct/RoomProvider.tsx +++ b/src/app/pages/client/direct/RoomProvider.tsx @@ -1,20 +1,29 @@ import type { ReactNode } from 'react'; import { Spinner } from 'folds'; import { useParams } from 'react-router-dom'; -import { useResolvedSelectedRoom } from '$hooks/router/useResolvedRoomId'; -import { IsDirectRoomProvider, RoomProvider } from '$hooks/useRoom'; +import { useResolvedRoomIdOrAlias } from '$hooks/router/useResolvedRoomId'; +import { IsDirectRoomProvider, DisplayedEventIdProvider, RoomProvider } from '$hooks/useRoom'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { JoinBeforeNavigate } from '$features/join-before-navigate'; import { useDirectRooms } from './useDirectRooms'; -export function DirectRouteRoomProvider({ children }: { children: ReactNode }) { +export function DirectRouteRoomProvider({ + roomIdOrAlias: roomIdOrAliasProp, + eventId: eventIdProp, + children, +}: { + roomIdOrAlias?: string; + eventId?: string; + children: ReactNode; +}) { const mx = useMatrixClient(); const rooms = useDirectRooms(); const { roomIdOrAlias: encodedRoomIdOrAlias, eventId: encodedEventId } = useParams(); - const roomIdOrAlias = encodedRoomIdOrAlias && decodeURIComponent(encodedRoomIdOrAlias); - const eventId = encodedEventId && decodeURIComponent(encodedEventId); - const { roomId, resolving } = useResolvedSelectedRoom(); + const roomIdOrAlias = + roomIdOrAliasProp ?? (encodedRoomIdOrAlias && decodeURIComponent(encodedRoomIdOrAlias)); + const eventId = eventIdProp ?? (encodedEventId && decodeURIComponent(encodedEventId)); + const { roomId, resolving } = useResolvedRoomIdOrAlias(roomIdOrAlias); const room = mx.getRoom(roomId); if (resolving) return ; @@ -25,7 +34,9 @@ export function DirectRouteRoomProvider({ children }: { children: ReactNode }) { return ( - {children} + + {children} + ); } diff --git a/src/app/pages/client/home/RoomProvider.tsx b/src/app/pages/client/home/RoomProvider.tsx index baba83a7f0..e36db87c9e 100644 --- a/src/app/pages/client/home/RoomProvider.tsx +++ b/src/app/pages/client/home/RoomProvider.tsx @@ -1,8 +1,8 @@ import type { ReactNode } from 'react'; import { Spinner } from 'folds'; import { useParams } from 'react-router-dom'; -import { useResolvedSelectedRoom } from '$hooks/router/useResolvedRoomId'; -import { IsDirectRoomProvider, RoomProvider } from '$hooks/useRoom'; +import { useResolvedRoomIdOrAlias } from '$hooks/router/useResolvedRoomId'; +import { IsDirectRoomProvider, DisplayedEventIdProvider, RoomProvider } from '$hooks/useRoom'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { JoinBeforeNavigate } from '$features/join-before-navigate'; import { useSearchParamsViaServers } from '$hooks/router/useSearchParamsViaServers'; @@ -10,16 +10,25 @@ import { useHomeRooms } from './useHomeRooms'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; -export function HomeRouteRoomProvider({ children }: { children: ReactNode }) { +export function HomeRouteRoomProvider({ + roomIdOrAlias: roomIdOrAliasProp, + eventId: eventIdProp, + children, +}: { + roomIdOrAlias?: string; + eventId?: string; + children: ReactNode; +}) { const mx = useMatrixClient(); const [isShowingAllRoomsInHome] = useSetting(settingsAtom, 'isShowingAllRoomsInHome'); const rooms = useHomeRooms(); const { roomIdOrAlias: encodedRoomIdOrAlias, eventId: encodedEventId } = useParams(); - const roomIdOrAlias = encodedRoomIdOrAlias && decodeURIComponent(encodedRoomIdOrAlias); - const eventId = encodedEventId && decodeURIComponent(encodedEventId); + const roomIdOrAlias = + roomIdOrAliasProp ?? (encodedRoomIdOrAlias && decodeURIComponent(encodedRoomIdOrAlias)); + const eventId = eventIdProp ?? (encodedEventId && decodeURIComponent(encodedEventId)); const viaServers = useSearchParamsViaServers(); - const { roomId, resolving } = useResolvedSelectedRoom(); + const { roomId, resolving } = useResolvedRoomIdOrAlias(roomIdOrAlias); const room = mx.getRoom(roomId); if (resolving) return ; @@ -36,7 +45,9 @@ export function HomeRouteRoomProvider({ children }: { children: ReactNode }) { return ( - {children} + + {children} + ); } diff --git a/src/app/pages/client/space/RoomProvider.tsx b/src/app/pages/client/space/RoomProvider.tsx index d85ba24920..bd5cada128 100644 --- a/src/app/pages/client/space/RoomProvider.tsx +++ b/src/app/pages/client/space/RoomProvider.tsx @@ -2,8 +2,8 @@ import type { ReactNode } from 'react'; import { Spinner } from 'folds'; import { useParams } from 'react-router-dom'; import { useAtom, useAtomValue } from 'jotai'; -import { useResolvedSelectedRoom } from '$hooks/router/useResolvedRoomId'; -import { IsDirectRoomProvider, RoomProvider } from '$hooks/useRoom'; +import { useResolvedRoomIdOrAlias } from '$hooks/router/useResolvedRoomId'; +import { IsDirectRoomProvider, DisplayedEventIdProvider, RoomProvider } from '$hooks/useRoom'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { JoinBeforeNavigate } from '$features/join-before-navigate'; import { useSpace } from '$hooks/useSpace'; @@ -15,7 +15,15 @@ import { mDirectAtom } from '$state/mDirectList'; import { settingsAtom } from '$state/settings'; import { useSetting } from '$state/hooks/settings'; -export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) { +export function SpaceRouteRoomProvider({ + roomIdOrAlias: roomIdOrAliasProp, + eventId: eventIdProp, + children, +}: { + roomIdOrAlias?: string; + eventId?: string; + children: ReactNode; +}) { const mx = useMatrixClient(); const space = useSpace(); const [developerTools] = useSetting(settingsAtom, 'developerTools'); @@ -24,10 +32,11 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) { const allRooms = useAtomValue(allRoomsAtom); const { roomIdOrAlias: encodedRoomIdOrAlias, eventId: encodedEventId } = useParams(); - const roomIdOrAlias = encodedRoomIdOrAlias && decodeURIComponent(encodedRoomIdOrAlias); - const eventId = encodedEventId && decodeURIComponent(encodedEventId); + const roomIdOrAlias = + roomIdOrAliasProp ?? (encodedRoomIdOrAlias && decodeURIComponent(encodedRoomIdOrAlias)); + const eventId = eventIdProp ?? (encodedEventId && decodeURIComponent(encodedEventId)); const viaServers = useSearchParamsViaServers(); - const { roomId, resolving } = useResolvedSelectedRoom(); + const { roomId, resolving } = useResolvedRoomIdOrAlias(roomIdOrAlias); const room = mx.getRoom(roomId); if (resolving) return ; @@ -47,7 +56,9 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) { // allow to view space timeline return ( - {children} + + {children} + ); } @@ -73,7 +84,9 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) { return ( - {children} + + {children} + ); } diff --git a/src/app/state/room/lastRoom.ts b/src/app/state/room/lastRoom.ts index 6f5470422e..8690b8008d 100644 --- a/src/app/state/room/lastRoom.ts +++ b/src/app/state/room/lastRoom.ts @@ -1,12 +1,15 @@ -import { atom } from 'jotai'; +import { + atomWithLocalStorage, + getLocalStorageItem, + setLocalStorageItem, +} from '$state/utils/atomWithLocalStorage'; -export type LastVisitedRoom = { - /** Section key the room was open under (see `resolveSection`). */ - section: string; - roomId: string; -}; +const LAST_VISITED_ROOM_KEY = 'sable.lastVisitedRoom'; -// This is only used for mobile swipe gestures -// It is not particularly accurate and shouldn't be used for much else -// unless you plan major refractors -export const lastVisitedRoomAtom = atom(undefined); +const lastVisitedRoomBaseAtom = atomWithLocalStorage>( + LAST_VISITED_ROOM_KEY, + (key) => getLocalStorageItem>(key, {}), + (key, value) => setLocalStorageItem(key, value) +); + +export const lastVisitedRoomAtom = lastVisitedRoomBaseAtom; diff --git a/src/app/state/utils/atomWithLocalStorage.ts b/src/app/state/utils/atomWithLocalStorage.ts index 08f3b2fd42..84f1aeea42 100644 --- a/src/app/state/utils/atomWithLocalStorage.ts +++ b/src/app/state/utils/atomWithLocalStorage.ts @@ -39,11 +39,13 @@ export const atomWithLocalStorage = ( }; }; - const localStorageAtom = atom( + const localStorageAtom = atom T)], undefined>( (get) => get(baseAtom), (get, set, newValue) => { - set(baseAtom, newValue); - setItem(key, newValue); + const resolved = + typeof newValue === 'function' ? (newValue as (prev: T) => T)(get(baseAtom)) : newValue; + set(baseAtom, resolved); + setItem(key, resolved); } ); diff --git a/src/app/utils/decryptScheduler.ts b/src/app/utils/decryptScheduler.ts new file mode 100644 index 0000000000..a970751383 --- /dev/null +++ b/src/app/utils/decryptScheduler.ts @@ -0,0 +1,46 @@ +import type { MatrixClient, MatrixEvent } from '$types/matrix-sdk'; + +const FRAME_BUDGET_MS = 10; + +const queue: Array<() => void> = []; +const channel = typeof MessageChannel !== 'undefined' ? new MessageChannel() : undefined; +let scheduled = false; + +const drain = () => { + scheduled = false; + const deadline = performance.now() + FRAME_BUDGET_MS; + while (queue.length > 0 && performance.now() < deadline) { + queue.shift()?.(); + } + if (queue.length > 0) schedule(); +}; + +if (channel) { + channel.port1.addEventListener('message', drain); + channel.port1.start(); +} + +function schedule(): void { + if (scheduled) return; + scheduled = true; + if (channel) channel.port2.postMessage(null); + else setTimeout(drain, 0); +} + +export function scheduleDecrypt(task: () => void): void { + queue.push(task); + schedule(); +} + +export function warmupRoomDecryption(mx: MatrixClient, roomId: string, count = 12): void { + const room = mx.getRoom(roomId); + if (!room) return; + const events: MatrixEvent[] = room.getLiveTimeline().getEvents().slice(-count); + events.forEach((event) => { + if (event.isEncrypted()) { + scheduleDecrypt(() => { + mx.decryptEventIfNeeded(event).catch(() => undefined); + }); + } + }); +}