Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-overlapping-panels-mobile-swipe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Smoother mobile navigation and faster room opening.
9 changes: 4 additions & 5 deletions src/app/components/BackRouteHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down
232 changes: 96 additions & 136 deletions src/app/components/page/MobileNavDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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)';
Expand All @@ -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 ||
Expand All @@ -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';
Expand All @@ -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;
Expand All @@ -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(
({
Expand All @@ -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;
}
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -249,31 +210,23 @@ 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);
}
return;
}

if (mx > DIRECTION_DEADZONE) {
if (draggingRef.current) {
draggingRef.current = false;
settle(0);
}
cancel();
return;
}
Expand All @@ -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 {
Expand Down Expand Up @@ -358,14 +317,7 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra
{rail && (
<div style={{ flexShrink: 0, display: 'flex', overflow: 'hidden' }}>{rail}</div>
)}
<div
style={{
flexGrow: 1,
minWidth: 0,
display: 'flex',
overflow: 'hidden',
}}
>
<div style={{ flexGrow: 1, minWidth: 0, display: 'flex', overflow: 'hidden' }}>
{nav}
</div>
</div>
Expand All @@ -381,7 +333,15 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra
overflow: 'hidden',
}}
>
{deferContent ? null : children}
{isRoomRoute ? (
<PersistentRoomHost inactive={panelIntent === 0} />
) : listView ? (
roomArmed ? (
<PersistentRoomHost inactive={panelIntent === 0} />
) : null
) : (
children
)}
</div>
</motion.div>
</div>
Expand Down
Loading
Loading