From 04d3367c88d87e0d0b32c89c6286356c4af4e59b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 26 Jun 2026 01:05:14 +0200 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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(); + } + }; + }, []); +} From 7a97fef33c2812fa125e239ac1d51b54243936b2 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 26 Jun 2026 01:42:12 +0200 Subject: [PATCH 5/5] =?UTF-8?q?chore(release):=200.16.2=20=E2=80=94=20acce?= =?UTF-8?q?ss-requests=20UX=20+=20back-navigation=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- frontend/src/lib/releaseNotes.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 2a0970c..201a22c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.16.1 +0.16.2 diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index b1a5e4a..2809643 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,15 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.16.2", + date: "2026-06-26", + summary: "Smoother navigation and tidier access-request handling.", + fixes: [ + "The browser/mouse Back button now steps back the way you'd expect: from an open message conversation it returns to your conversation list (not the previous module), and it closes the video player or an open dialog instead of jumping off the page.", + "Access requests: the “Review” link opens straight to the Access requests tab, the notice clears itself the moment you approve (no refresh needed), and a leftover sign-in link no longer lingers in the address bar.", + ], + }, { version: "0.16.1", date: "2026-06-26",