From 04d3367c88d87e0d0b32c89c6286356c4af4e59b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 26 Jun 2026 01:05:14 +0200 Subject: [PATCH 1/4] fix(notifications): Review link opens the Access requests tab, not the last-used one The access-requests notice's Review link navigated to the Users page but the persisted tab (often Demo) loaded instead. Pre-select the access tab before navigating (focusAccessRequestsTab writes the persisted-tab key, which the page reads on its fresh mount). --- frontend/src/App.tsx | 10 ++++++++-- frontend/src/components/AdminUsers.tsx | 12 +++++++++++- frontend/src/components/NotificationsPanel.tsx | 6 +++++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 20c5dd3..8ac7595 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,7 +32,7 @@ import Playlists from "./components/Playlists"; import Stats from "./components/Stats"; import Scheduler from "./components/Scheduler"; import ConfigPanel from "./components/ConfigPanel"; -import AdminUsers from "./components/AdminUsers"; +import AdminUsers, { focusAccessRequestsTab } from "./components/AdminUsers"; import SettingsPanel from "./components/SettingsPanel"; import NotificationsPanel from "./components/NotificationsPanel"; import Messages from "./components/Messages"; @@ -372,7 +372,13 @@ export default function App() { // Durable inbox link (survives reload, unlike a live action callback); the toast also // gets a click via the action. meta: { kind: "access-requests" }, - action: { label: t("common.accessRequestsReview"), onClick: () => setPage("users") }, + action: { + label: t("common.accessRequestsReview"), + onClick: () => { + focusAccessRequestsTab(); + setPage("users"); + }, + }, }); } } diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 8c6aeae..e90b8e5 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -11,9 +11,19 @@ import Tabs, { usePersistedTab } from "./Tabs"; // Admin-only user & access management: roles, access requests (the Invite whitelist), and the // demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab // carries a badge with the count of pending requests so it's visible without switching to it. +// The persisted-tab storage key + the access-requests tab id, exported so a notification's +// "Review" link can pre-select that tab before navigating here (usePersistedTab reads the key +// on mount, and this page mounts fresh on navigation). +export const ADMIN_USERS_TAB_KEY = "siftlode.adminUsersTab"; +export const ADMIN_USERS_ACCESS_TAB = "access"; + +export function focusAccessRequestsTab(): void { + localStorage.setItem(ADMIN_USERS_TAB_KEY, ADMIN_USERS_ACCESS_TAB); +} + export default function AdminUsers({ me }: { me: Me }) { const { t } = useTranslation(); - const [tab, setTab] = usePersistedTab("siftlode.adminUsersTab", "roles"); + const [tab, setTab] = usePersistedTab(ADMIN_USERS_TAB_KEY, "roles"); const tabs = [ { id: "roles", label: t("users.tabs.roles") }, { id: "access", label: t("users.tabs.access"), badge: me.pending_invites }, diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index e4883b5..044074d 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -4,6 +4,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Activity, Bell, Check, CheckCheck, ExternalLink, Eye, RotateCcw, Search, Trash2, Tv, UserPlus, X } from "lucide-react"; import { api, type AppNotification, type FeedFilters } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; +import { focusAccessRequestsTab } from "./AdminUsers"; import { clearAll as clearClient, dismiss as dismissClient, @@ -190,7 +191,10 @@ export default function NotificationsPanel({ onFind={locate} onRevert={revertState} onFocusChannel={onFocusChannel} - onReviewAccess={() => setPage("users")} + onReviewAccess={() => { + focusAccessRequestsTab(); + setPage("users"); + }} onRemove={() => removeClient(n.id)} /> ))} From de58ded923463a994dc08f477f3adcdaa6369717 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 26 Jun 2026 01:13:26 +0200 Subject: [PATCH 2/4] fix(url): strip leftover query string once signed in The pre-login ?access=requested (and ?verify/?reset) lingered in the address bar after login. Strip the query string on auth (the logged-in app reads nothing from the URL); Welcome still reads its params while logged out. stripUrlParams now preserves history.state so it's safe to call after the in-app page stamp. --- frontend/src/App.tsx | 11 +++++++++-- frontend/src/lib/urlState.ts | 7 ++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8ac7595..502e2eb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -234,8 +234,8 @@ export default function App() { }, []); // eslint-disable-line react-hooks/exhaustive-deps // In-app Back/Forward: stamp the initial entry with the current page, then sync `page` - // from history.state on popstate. Runs after the strip-params effect above (which resets - // history.state to null), so the initial stamp isn't clobbered. + // from history.state on popstate. (stripUrlParams preserves history.state, so this stamp + // survives a later query-string strip.) useEffect(() => { window.history.replaceState( { ...window.history.state, sfPage: page }, @@ -288,6 +288,13 @@ export default function App() { refetchInterval: (query) => (query.state.data ? 60_000 : false), }); + // Once signed in, drop any leftover query string from the pre-login flow (e.g. + // ?access=requested, ?verify, ?reset). The logged-in app reads nothing from the URL, so the + // address bar stays clean. Gated on auth so the logged-out Welcome page still reads its params. + useEffect(() => { + if (meQuery.data) stripUrlParams(); + }, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps + // Any request that 401s means the session ended server-side. If we were signed in (cached // `me` exists), reload to the login page at once (option 2 — also covers any click that hits // the API). Guarded on cached `me` so the public login page's own /api/me 401 can't loop. diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index 85c0282..7311d3a 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -115,10 +115,11 @@ export function shareUrl(f: FeedFilters, page: Page = "feed"): string { return `${window.location.origin}${window.location.pathname}${qs ? `?${qs}` : ""}`; } -/** Strip any filter/page query params from the address bar (after hydrating from a share - * link), leaving a clean URL without touching history. */ +/** Strip the query string from the address bar (after hydrating from a share/auth link), + * leaving a clean URL. Preserves the current history.state (e.g. the in-app `sfPage` stamp) + * so it's safe to call at any time, not just before that stamp is written. */ export function stripUrlParams(): void { if (window.location.search) { - window.history.replaceState(null, "", window.location.pathname); + window.history.replaceState(window.history.state, "", window.location.pathname); } } From ad77a1751e6507c7e3b041a99d25ebafbfb3cfc8 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 26 Jun 2026 01:19:01 +0200 Subject: [PATCH 3/4] fix(notifications): access-requests nudge auto-clears on approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin access-requests notice was issued from the on-user-load effect (keyed on user id), so it only re-resolved on a fresh load — it lingered until F5 after an approval dropped the pending count to 0. Move it to its own effect keyed on the pending count, so approving/denying (which refetches /api/me) clears or re-counts the notice live. --- frontend/src/App.tsx | 53 ++++++++++++++++++++++++-------------------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 502e2eb..9489035 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -365,30 +365,6 @@ export default function App() { saveLayoutLocal(l); } if (isSupported(prefs.language)) setLanguage(prefs.language); - // Nudge admins when access requests are waiting (in lieu of a server-push bell). - const pending = meQuery.data?.pending_invites ?? 0; - if (meQuery.data?.role === "admin") { - // Keep a single, current nudge: drop any prior one (persisted copies reload as dismissed - // history, so they'd otherwise pile up one-per-reload) before re-issuing the live notice. - removeByMetaKind("access-requests"); - if (pending > 0) { - notify({ - level: "warning", - title: t("common.accessRequestsTitle"), - message: t("common.accessRequestsMessage", { count: pending }), - // Durable inbox link (survives reload, unlike a live action callback); the toast also - // gets a click via the action. - meta: { kind: "access-requests" }, - action: { - label: t("common.accessRequestsReview"), - onClick: () => { - focusAccessRequestsTab(); - setPage("users"); - }, - }, - }); - } - } // The demo account is shared: there are no subscriptions, so default it to the whole // library (the "my" feed would be empty). The communal-state warning is a permanent // banner (see DemoBanner below), not a toast that re-pops on every reload. @@ -398,6 +374,35 @@ export default function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [meQuery.data?.id]); + // Nudge admins when access requests are waiting (in lieu of a server-push bell). Keyed on the + // pending COUNT so it re-resolves the moment an approval/denial changes it — not only on a + // fresh load (which is why the notice used to linger until F5 after an approval). + useEffect(() => { + if (meQuery.data?.role !== "admin") return; + // Keep a single, current nudge: drop any prior one (persisted copies reload as dismissed + // history, so they'd otherwise pile up one-per-reload) before re-issuing the live notice. + removeByMetaKind("access-requests"); + const pending = meQuery.data.pending_invites ?? 0; + if (pending > 0) { + notify({ + level: "warning", + title: t("common.accessRequestsTitle"), + message: t("common.accessRequestsMessage", { count: pending }), + // Durable inbox link (survives reload, unlike a live action callback); the toast also + // gets a click via the action. + meta: { kind: "access-requests" }, + action: { + label: t("common.accessRequestsReview"), + onClick: () => { + focusAccessRequestsTab(); + setPage("users"); + }, + }, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [meQuery.data?.role, meQuery.data?.pending_invites]); + // Theme is part of the Settings draft: apply locally for preview, persist on Save. function setTheme(next: ThemePrefs) { setThemeState(next); From 71f79fc73d79e34b8dac9888e52df715368d93c3 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 26 Jun 2026 01:37:43 +0200 Subject: [PATCH 4/4] 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(); + } + }; + }, []); +}