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:
parent
ad77a1751e
commit
71f79fc73d
5 changed files with 93 additions and 6 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
76
frontend/src/lib/history.ts
Normal file
76
frontend/src/lib/history.ts
Normal file
|
|
@ -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<T>(root: T): {
|
||||
view: T;
|
||||
open: (next: T) => void;
|
||||
back: () => void;
|
||||
} {
|
||||
const [view, setView] = useState<T>(() => (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();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue