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