(
if (next && next !== editableRef.current && next.isContentEditable) return;
ReactEditor.focus(editor);
}}
+ // Once the virtual keyboard has settled, make sure the composer is
+ // not left hidden behind it.
+ onFocus={() => {
+ if (!mobileOrTablet()) return;
+ window.setTimeout(() => {
+ rootRef.current?.scrollIntoView({ block: 'nearest' });
+ }, 300);
+ }}
style={{ boxShadow: 'none' }}
/>
diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx
index ab3fde5624..8f141ce174 100644
--- a/src/app/components/image-viewer/ImageViewer.tsx
+++ b/src/app/components/image-viewer/ImageViewer.tsx
@@ -27,6 +27,7 @@ import {
sizedIcon,
} from '$components/icons/phosphor';
import { useImageGestures } from '$hooks/useImageGestures';
+import { useAndroidBackHandler } from '$utils/androidBack';
import { useSetting } from '$state/hooks/settings';
import { isPixelatedRendering, settingsAtom } from '$state/settings';
import { downloadMedia } from '$utils/matrix';
@@ -51,6 +52,12 @@ export const ImageViewer = as<'div', ImageViewerProps>(
const zoomInputRef = useRef
(null);
const [pixelatedImageRendering] = useSetting(settingsAtom, 'pixelatedImageRendering');
+ // Android back closes the viewer instead of navigating away.
+ useAndroidBackHandler(() => {
+ requestClose();
+ return true;
+ });
+
const [isImageReady, setIsImageReady] = useState(false);
const [isEditingZoom, setIsEditingZoom] = useState(false);
const [zoomInput, setZoomInput] = useState('100');
diff --git a/src/app/components/page/MobileNavDrawer.tsx b/src/app/components/page/MobileNavDrawer.tsx
index 96f4647d88..963db0b3f8 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,9 @@ import {
SPACE_ROOM_PATH,
} from '$pages/paths';
import { resolveSection } from '$pages/pathUtils';
+import { haptic } from '$utils/haptics';
+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 +43,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 +79,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 +115,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 +137,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 +180,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 +192,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 +202,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 +211,17 @@ 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) {
+ haptic('light');
+ 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 +229,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 +240,29 @@ 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);
+ haptic('light');
+ panelIntentRef.current = 1;
+ setPanelIntent(1);
+ settle(-width);
+ startTransition(() => navigate(roomPath));
+ } else {
+ settle(0);
+ }
} else {
- goToList();
settle(0);
}
} else {
@@ -358,14 +320,7 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra
{rail && (
{rail}
)}
-
@@ -381,7 +336,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 =