fix(nav): Back steps through sub-views and closes modals before leaving a page

In-app history only tracked the top-level page, so Back from a module sub-view
(e.g. a Messages thread) or with a modal open jumped straight to the previous
module. Add two history primitives: useHistorySubview (a module's sub-view rides
in history.state, so Back returns to its root first) and useBackToClose (a
mounted overlay occupies one history entry; Back closes the topmost, nesting-safe
so a button-close doesn't trip the modals underneath). Apply to the Messages page
views and to PlayerModal + the shared Modal. setPage now pushes a clean entry so
each page starts at its root.
This commit is contained in:
npeter83 2026-06-26 01:37:43 +02:00
parent ad77a1751e
commit 71f79fc73d
5 changed files with 93 additions and 6 deletions

View file

@ -6,6 +6,7 @@ import { api, type Conversation, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { relativeTime } from "../lib/format";
import { partnerPub, SYSTEM_ID, useKeyState } from "../lib/messaging";
import { useHistorySubview } from "../lib/history";
import { openChat } from "../lib/chatDock";
import * as e2ee from "../lib/e2ee";
import Avatar from "./Avatar";
@ -17,7 +18,9 @@ const POLL_MS = 20000;
type View = "list" | "new" | { partnerId: number; name?: string; avatar?: string | null; system?: boolean };
export default function Messages({ meId }: { meId: number }) {
const [view, setView] = useState<View>("list");
// Sub-views live in browser history, so Back returns to the conversation list before leaving
// the module (open() pushes an entry; back() = history.back()).
const { view, open, back } = useHistorySubview<View>("list");
if (typeof view === "object") {
return (
@ -27,18 +30,18 @@ export default function Messages({ meId }: { meId: number }) {
isSystem={!!view.system}
seedName={view.name}
seedAvatar={view.avatar}
onBack={() => setView("list")}
onBack={back}
/>
);
}
if (view === "new") {
return <Directory onPick={(u) => setView({ partnerId: u.id, name: u.name, avatar: u.avatar_url })} onBack={() => setView("list")} />;
return <Directory onPick={(u) => open({ partnerId: u.id, name: u.name, avatar: u.avatar_url })} onBack={back} />;
}
return (
<ConversationList
meId={meId}
onOpen={(c) => setView({ partnerId: c.partner.id, name: c.partner.name, avatar: c.partner.avatar_url, system: c.partner.id === SYSTEM_ID })}
onNew={() => setView("new")}
onOpen={(c) => open({ partnerId: c.partner.id, name: c.partner.name, avatar: c.partner.avatar_url, system: c.partner.id === SYSTEM_ID })}
onNew={() => open("new")}
/>
);
}

View file

@ -1,6 +1,7 @@
import { useEffect, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { useBackToClose } from "../lib/history";
// Stack of open modals so ESC only closes the topmost one — e.g. an error dialog over the
// tag editor: pressing ESC dismisses just the error and returns to the editor underneath.
@ -19,6 +20,9 @@ export default function Modal({
children: ReactNode;
maxWidth?: string;
}) {
// Browser/mouse Back closes the topmost modal instead of navigating away.
useBackToClose(onClose);
// Keep a stable handler across renders so the stack id is assigned once per mount.
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;

View file

@ -8,6 +8,7 @@ import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import { api, type Video } from "../lib/api";
import { channelYouTubeUrl, formatDuration, formatViews, relativeTime } from "../lib/format";
import { useBackToClose } from "../lib/history";
// Turn a description into clickable nodes:
// - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player
@ -203,6 +204,8 @@ export default function PlayerModal({
}) {
const { t, i18n } = useTranslation();
const qc = useQueryClient();
// Browser/mouse Back closes the player instead of leaving the page.
useBackToClose(onClose);
// Precise upload date shown inline after the relative time (e.g. "9 yr ago · 12 Jan 2017").
const fullDate = (d: string | null | undefined) =>
d