From 71f79fc73d79e34b8dac9888e52df715368d93c3 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 26 Jun 2026 01:37:43 +0200 Subject: [PATCH] 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. --- frontend/src/App.tsx | 3 +- frontend/src/components/Messages.tsx | 13 +++-- frontend/src/components/Modal.tsx | 4 ++ frontend/src/components/PlayerModal.tsx | 3 + frontend/src/lib/history.ts | 76 +++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 frontend/src/lib/history.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9489035..2176d6a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -186,7 +186,8 @@ export default function App() { // Push an in-app history entry so the browser/mouse Back button steps through pages // instead of leaving the app (e.g. back to the OAuth redirect). The URL stays clean — // the page rides in history.state, not the query string (filters never go in the URL). - window.history.pushState({ ...window.history.state, sfPage: next }, ""); + // A fresh { sfPage } (no carried-over _sub/_ov markers) so each page starts at its root. + window.history.pushState({ sfPage: next }, ""); }; // Guard leaving the Settings page with unsaved preference changes. if (page === "settings" && prefsDirtyRef.current) { diff --git a/frontend/src/components/Messages.tsx b/frontend/src/components/Messages.tsx index 231d672..0e514be 100644 --- a/frontend/src/components/Messages.tsx +++ b/frontend/src/components/Messages.tsx @@ -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("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("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 setView({ partnerId: u.id, name: u.name, avatar: u.avatar_url })} onBack={() => setView("list")} />; + return open({ partnerId: u.id, name: u.name, avatar: u.avatar_url })} onBack={back} />; } return ( 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")} /> ); } diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx index eaa6e76..16699ba 100644 --- a/frontend/src/components/Modal.tsx +++ b/frontend/src/components/Modal.tsx @@ -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; diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 3e5e90c..fcbc7bb 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -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 diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts new file mode 100644 index 0000000..46362c8 --- /dev/null +++ b/frontend/src/lib/history.ts @@ -0,0 +1,76 @@ +// In-app browser-history integration so Back/Forward step through a module's sub-views and +// overlays before leaving the page — not straight back to the previous module. +// +// Two primitives share the existing page-history (App stamps `sfPage` in history.state): +// - useHistorySubview: a module's active sub-view rides in history.state._sub, so Back inside +// a module (e.g. Messages thread → list) returns to the parent view first. +// - useBackToClose: while an overlay/modal is mounted it occupies one history entry; Back +// closes the topmost one. A module-level stack keeps nesting correct — closing one modal by +// its own button pops exactly its entry without tripping the modals underneath. +// +// setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root. +import { useEffect, useRef, useState } from "react"; + +// --- Sub-views (value carried in history.state._sub) --------------------------------------- +export function useHistorySubview(root: T): { + view: T; + open: (next: T) => void; + back: () => void; +} { + const [view, setView] = useState(() => (window.history.state?._sub as T) ?? root); + useEffect(() => { + const onPop = () => setView((window.history.state?._sub as T) ?? root); + window.addEventListener("popstate", onPop); + return () => window.removeEventListener("popstate", onPop); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return { + view, + open: (next: T) => { + setView(next); + window.history.pushState({ ...window.history.state, _sub: next }, ""); + }, + back: () => window.history.back(), + }; +} + +// --- Overlays / modals (one history entry each, nesting-safe) ------------------------------- +const overlayStack: number[] = []; +let overlayCounter = 0; +// Set while we pop our OWN entry programmatically (button/ESC close) so the other overlays' +// popstate handlers don't mistake it for a user Back and close themselves too. +let suppressPop = false; + +/** While the calling component is mounted, Back (or the browser/mouse back button) closes it via + * `onClose` instead of navigating away. Mount the component only while the overlay is open. */ +export function useBackToClose(onClose: () => void): void { + const cb = useRef(onClose); + cb.current = onClose; + useEffect(() => { + const token = ++overlayCounter; + overlayStack.push(token); + window.history.pushState({ ...window.history.state, _ov: token }, ""); + const onPop = () => { + if (suppressPop) { + suppressPop = false; + return; + } + if (overlayStack[overlayStack.length - 1] === token) { + overlayStack.pop(); + cb.current(); + } + }; + window.addEventListener("popstate", onPop); + return () => { + window.removeEventListener("popstate", onPop); + const idx = overlayStack.lastIndexOf(token); + if (idx !== -1) { + // Closed programmatically (not via Back): drop our own history entry, and tell the + // remaining overlays' handlers to ignore the resulting popstate. + overlayStack.splice(idx, 1); + suppressPop = true; + window.history.back(); + } + }; + }, []); +}