From 978516b9946b7ea6596e75872988284fadda952d Mon Sep 17 00:00:00 2001 From: kipavy Date: Tue, 18 Aug 2026 19:57:56 +0000 Subject: [PATCH] feat(deeplink): add the notification, settings and billing routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 3 of #144, navigate half. A third trust class joins confirm and silent: navigate routes only move the user to a screen they could already reach, so they act without a prompt but never perform an action — billing opens the account section and deliberately starts no checkout. SettingsSection becomes a runtime list so a link's section can be checked rather than trusted; the store keeps the notification centre's open state so a link can raise it, which the bell previously held in local state. The mobile shell keeps a second bell mounted behind `invisible` and the popover is portalled to the body, so each bell measures its own visibility and the off-screen one leaves the popover to the visible one. --- .../notifications/NotificationBell.test.tsx | 69 ++++++++++++++++ .../notifications/NotificationBell.tsx | 57 ++++++++++--- src/services/deepLink.test.ts | 47 ++++++++--- src/services/deepLink.ts | 6 +- src/services/deepLinkHandlers.test.ts | 49 +++++++++-- src/services/deepLinkHandlers.ts | 45 +++++++++-- src/services/deepLinkUrl.test.ts | 67 ++++++++++++++- src/services/deepLinkUrl.ts | 81 ++++++++++++++++--- src/stores/deepLinkStore.ts | 34 ++++---- src/stores/uiStore.ts | 24 +++++- 10 files changed, 413 insertions(+), 66 deletions(-) create mode 100644 src/components/notifications/NotificationBell.test.tsx diff --git a/src/components/notifications/NotificationBell.test.tsx b/src/components/notifications/NotificationBell.test.tsx new file mode 100644 index 000000000..e5db931ef --- /dev/null +++ b/src/components/notifications/NotificationBell.test.tsx @@ -0,0 +1,69 @@ +import { test, expect, afterEach, beforeEach, vi } from "vitest"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { NotificationBell } from "./NotificationBell"; +import { useNotificationStore } from "@/stores/notificationStore"; +import { useUIStore } from "@/stores/uiStore"; + +vi.mock("@/i18n", () => ({ default: { t: (key: string) => key } })); + +afterEach(cleanup); + +beforeEach(() => { + useUIStore.setState({ notificationCenterOpen: false, notificationFocusId: null }); + useNotificationStore.setState({ inbox: [], banners: [], history: [] }); +}); + +test("the popover follows the store, so a deep link can raise it", () => { + render(); + expect(screen.queryByText("notifications.bell.clearHistory")).toBeNull(); + act(() => useUIStore.getState().openNotificationCenter(null)); + expect(screen.getByText("notifications.bell.clearHistory")).toBeTruthy(); +}); + +test("a bell that computes as hidden leaves the popover to the visible one", () => { + // The mobile shell keeps the SFTP tab's bell mounted under `invisible`, and + // the popover is portalled to the body, where an ancestor's visibility no + // longer hides it. jsdom does not inherit `visibility` down the tree, so the + // computed value is stubbed rather than set on a wrapper. + const real = window.getComputedStyle; + window.getComputedStyle = ((el: Element) => + el instanceof HTMLButtonElement + ? ({ visibility: "hidden" } as CSSStyleDeclaration) + : real(el)) as typeof window.getComputedStyle; + try { + render(); + act(() => useUIStore.getState().openNotificationCenter(null)); + expect(screen.queryByText("notifications.bell.clearHistory")).toBeNull(); + } finally { + window.getComputedStyle = real; + } +}); + +test("an entry the link names is scrolled to and the focus is cleared", () => { + const scrollIntoView = vi.fn(); + Element.prototype.scrollIntoView = scrollIntoView; + useNotificationStore.setState({ + inbox: [ + { + id: "invite:42", + kind: "invite", + message: "an invitation", + actions: [], + source: { kind: "app", area: "team" }, + state: "pending", + createdAt: Date.now(), + }, + ], + }); + render(); + act(() => useUIStore.getState().openNotificationCenter("invite:42")); + expect(scrollIntoView).toHaveBeenCalled(); + expect(useUIStore.getState().notificationFocusId).toBeNull(); +}); + +test("an id no longer in the inbox still leaves the popover open", () => { + render(); + act(() => useUIStore.getState().openNotificationCenter("invite:gone")); + expect(screen.getByText("notifications.bell.clearHistory")).toBeTruthy(); + expect(useUIStore.getState().notificationFocusId).toBeNull(); +}); diff --git a/src/components/notifications/NotificationBell.tsx b/src/components/notifications/NotificationBell.tsx index 124f2a196..ef8d86475 100644 --- a/src/components/notifications/NotificationBell.tsx +++ b/src/components/notifications/NotificationBell.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import i18n from "@/i18n"; import { Icon } from "@iconify/react"; import { useNotificationStore } from "@/stores/notificationStore"; +import { useUIStore } from "@/stores/uiStore"; import type { BannerEntry, HistoryEntry, InboxEntry } from "@/stores/notificationStore"; const SEVERITY_ICONS: Record = { @@ -32,6 +33,7 @@ function InboxRow({ entry, onAction }: { entry: InboxEntry; onAction: (i: number const resolved = entry.state === "resolved"; return (
s.runInboxAction); const clearHistory = useNotificationStore((s) => s.clearHistory); - const [open, setOpen] = useState(false); - const [pos, setPos] = useState({ top: 0, right: 0 }); + // Open state lives in the store so a `notification` deep link can raise the + // popover; the bell is the only thing that renders it. + const open = useUIStore((s) => s.notificationCenterOpen); + const setOpen = useUIStore((s) => s.setNotificationCenterOpen); + const focusId = useUIStore((s) => s.notificationFocusId); + const clearFocus = useUIStore((s) => s.clearNotificationFocus); + const [pos, setPos] = useState<{ top: number; right: number } | null>(null); const buttonRef = useRef(null); const dropdownRef = useRef(null); + // Placement is measured on every open, not on click: a deep link opens the + // popover with no pointer event to measure from. + // + // The mobile shell keeps a second bell mounted behind `invisible` (the SFTP + // tab), and the popover is portalled to the body, where an ancestor's + // visibility no longer hides it. Measuring resolves which bell is on screen: + // `visibility` inherits, so the off-screen one bails and leaves the popover + // to the bell the user can actually see. + useEffect(() => { + const button = buttonRef.current; + if (!open || !button) return; + if (getComputedStyle(button).visibility === "hidden") { + setPos(null); + return; + } + const rect = button.getBoundingClientRect(); + setPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right }); + }, [open]); + + // A stale id is normal — inbox entries are re-derived, not stored — so a miss + // leaves the popover open on the full list rather than reporting anything. + useEffect(() => { + // `pos` gates the portal, so the popover exists only from the pass after it + // is measured; without it in the deps the scroll would fire against nothing. + if (!open || !focusId || !dropdownRef.current) return; + // Matched by attribute value rather than a built selector: ids carry `:` + // and would otherwise need escaping. + for (const row of dropdownRef.current.querySelectorAll("[data-inbox-id]")) { + if (row.getAttribute("data-inbox-id") === focusId) { + row.scrollIntoView({ block: "nearest" }); + break; + } + } + clearFocus(); + }, [open, pos, focusId, inbox, clearFocus]); + useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { @@ -172,13 +215,7 @@ export function NotificationBell() { return () => document.removeEventListener("mousedown", handler); }, [open]); - const handleOpen = () => { - if (buttonRef.current) { - const rect = buttonRef.current.getBoundingClientRect(); - setPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right }); - } - setOpen((o) => !o); - }; + const handleOpen = () => setOpen(!open); const displayCount = Math.min(unreadCount, 9); const hasItems = inbox.length > 0 || banners.length > 0 || history.length > 0; @@ -224,7 +261,7 @@ export function NotificationBell() {
- {open && createPortal( + {open && pos && createPortal(
`voltius://join?s=${s}&t=tok`; beforeEach(() => { useDeepLinkStore.setState({ ready: false, queue: [], prompt: null }); - useDeepLinkStore.getState().setSilentHandler(null); + useDeepLinkStore.getState().setUnpromptedHandler(null); }); test("a link arriving before ready is queued, not prompted", () => { @@ -102,7 +102,7 @@ const OTHER_USER = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; test("two verified links for different users both reach the silent handler", () => { const seen: string[] = []; - useDeepLinkStore.getState().setSilentHandler((i) => seen.push(i.userId)); + useDeepLinkStore.getState().setUnpromptedHandler((i) => seen.push(i.route === "verified" ? i.userId : i.route)); useDeepLinkStore.getState().setReady(true); handleDeepLink(`voltius://verified?u=${USER}`); handleDeepLink(`voltius://verified?u=${OTHER_USER}`); @@ -111,7 +111,7 @@ test("two verified links for different users both reach the silent handler", () test("a silent link does not wait behind an open prompt", () => { const seen: string[] = []; - useDeepLinkStore.getState().setSilentHandler((i) => seen.push(i.userId)); + useDeepLinkStore.getState().setUnpromptedHandler((i) => seen.push(i.route === "verified" ? i.userId : i.route)); useDeepLinkStore.getState().setReady(true); handleDeepLink(link(SESSION)); handleDeepLink(`voltius://verified?u=${USER}`); @@ -121,7 +121,7 @@ test("a silent link does not wait behind an open prompt", () => { test("a silent link arriving before ready runs once ready", () => { const seen: string[] = []; - useDeepLinkStore.getState().setSilentHandler((i) => seen.push(i.userId)); + useDeepLinkStore.getState().setUnpromptedHandler((i) => seen.push(i.route === "verified" ? i.userId : i.route)); handleDeepLink(`voltius://verified?u=${USER}`); expect(seen).toEqual([]); useDeepLinkStore.getState().setReady(true); @@ -130,7 +130,7 @@ test("a silent link arriving before ready runs once ready", () => { test("the same silent link delivered twice reaches the handler once", () => { const seen: string[] = []; - useDeepLinkStore.getState().setSilentHandler((i) => seen.push(i.userId)); + useDeepLinkStore.getState().setUnpromptedHandler((i) => seen.push(i.route === "verified" ? i.userId : i.route)); useDeepLinkStore.getState().setReady(true); handleDeepLink(`voltius://verified?u=${USER}`); handleDeepLink(`voltius://verified?u=${USER}`); @@ -141,7 +141,7 @@ test("the same silent link delivered again after the echo window reaches the han vi.useFakeTimers(); try { const seen: string[] = []; - useDeepLinkStore.getState().setSilentHandler((i) => seen.push(i.userId)); + useDeepLinkStore.getState().setUnpromptedHandler((i) => seen.push(i.route === "verified" ? i.userId : i.route)); useDeepLinkStore.getState().setReady(true); handleDeepLink(`voltius://verified?u=${USER}`); vi.advanceTimersByTime(5001); @@ -153,7 +153,7 @@ test("the same silent link delivered again after the echo window reaches the han }); test("a link enqueued by a silent handler survives the drain that ran it", () => { - useDeepLinkStore.getState().setSilentHandler(() => handleDeepLink(link(SESSION))); + useDeepLinkStore.getState().setUnpromptedHandler(() => handleDeepLink(link(SESSION))); useDeepLinkStore.getState().setReady(true); handleDeepLink(`voltius://verified?u=${USER}`); expect(useDeepLinkStore.getState().prompt?.sessionId).toBe(SESSION); @@ -161,10 +161,10 @@ test("a link enqueued by a silent handler survives the drain that ran it", () => }); // The cast is the point: a future route whose trust class the store does not -// recognise must be dropped, not fall through to the silent handler. +// recognise must be dropped, not fall through to the unprompted handler. test("an intent of an unknown trust class is dropped rather than acted on", () => { const seen: string[] = []; - useDeepLinkStore.getState().setSilentHandler((i) => seen.push(i.userId)); + useDeepLinkStore.getState().setUnpromptedHandler((i) => seen.push(i.route === "verified" ? i.userId : i.route)); useDeepLinkStore.getState().setReady(true); useDeepLinkStore.getState().enqueue({ route: "future" } as unknown as DeepLinkIntent); const s = useDeepLinkStore.getState(); @@ -186,3 +186,32 @@ test("the queue drops the oldest beyond its cap", () => { expect(queued).toHaveLength(4); expect(queued[0]).toMatchObject({ sessionId: ids[1] }); }); + +test("a navigate link reaches the handler without opening a prompt", () => { + const seen: string[] = []; + useDeepLinkStore.getState().setUnpromptedHandler((i) => seen.push(i.route)); + useDeepLinkStore.getState().setReady(true); + handleDeepLink("voltius://settings?section=vaults"); + handleDeepLink("voltius://billing"); + expect(seen).toEqual(["settings", "billing"]); + expect(useDeepLinkStore.getState().prompt).toBeNull(); +}); + +test("a navigate link does not wait behind an open prompt", () => { + const seen: string[] = []; + useDeepLinkStore.getState().setUnpromptedHandler((i) => seen.push(i.route)); + useDeepLinkStore.getState().setReady(true); + handleDeepLink(link(SESSION)); + handleDeepLink("voltius://notification?n=invite%3A42"); + expect(seen).toEqual(["notification"]); + expect(useDeepLinkStore.getState().prompt?.sessionId).toBe(SESSION); +}); + +test("the same navigate link delivered twice reaches the handler once", () => { + const seen: string[] = []; + useDeepLinkStore.getState().setUnpromptedHandler((i) => seen.push(i.route)); + useDeepLinkStore.getState().setReady(true); + handleDeepLink("voltius://settings?section=vaults"); + handleDeepLink("voltius://settings?section=vaults"); + expect(seen).toEqual(["settings"]); +}); diff --git a/src/services/deepLink.ts b/src/services/deepLink.ts index 7257fe3e0..f0424f313 100644 --- a/src/services/deepLink.ts +++ b/src/services/deepLink.ts @@ -1,4 +1,4 @@ -import { handleSilentIntent } from "@/services/deepLinkHandlers"; +import { handleUnpromptedIntent } from "@/services/deepLinkHandlers"; import { parseDeepLink } from "@/services/deepLinkUrl"; import { useDeepLinkStore } from "@/stores/deepLinkStore"; import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"; @@ -20,7 +20,7 @@ export function startDeepLinks(): () => void { let unlisten: (() => void) | null = null; let stopped = false; - useDeepLinkStore.getState().setSilentHandler(handleSilentIntent); + useDeepLinkStore.getState().setUnpromptedHandler(handleUnpromptedIntent); void getCurrent() .then((urls) => urls?.forEach(handleDeepLink)) @@ -35,7 +35,7 @@ export function startDeepLinks(): () => void { return () => { stopped = true; - useDeepLinkStore.getState().setSilentHandler(null); + useDeepLinkStore.getState().setUnpromptedHandler(null); unlisten?.(); }; } diff --git a/src/services/deepLinkHandlers.test.ts b/src/services/deepLinkHandlers.test.ts index e82bf58b9..0140ea90f 100644 --- a/src/services/deepLinkHandlers.test.ts +++ b/src/services/deepLinkHandlers.test.ts @@ -43,7 +43,8 @@ vi.mock("@/stores/notificationStore", () => ({ useNotificationStore: { getState: () => ({ addToast }) }, })); -import { handleSilentIntent } from "./deepLinkHandlers"; +import { handleUnpromptedIntent } from "./deepLinkHandlers"; +import { useUIStore } from "@/stores/uiStore"; beforeEach(() => { for (const key of Object.keys(keychain)) delete keychain[key]; @@ -57,7 +58,7 @@ beforeEach(() => { test("a link for the active account refreshes the verification state once", async () => { keychain.jwt = jwtFor(USER); - handleSilentIntent({ route: "verified", userId: USER }); + handleUnpromptedIntent({ route: "verified", userId: USER }); await vi.waitFor(() => expect(addToast).toHaveBeenCalled()); expect(refreshVerificationState).toHaveBeenCalledTimes(1); expect(addToast.mock.calls[0][0].severity).toBe("success"); @@ -67,7 +68,7 @@ test("a link for the active account refreshes the verification state once", asyn test("a refresh that leaves the account unverified reports pending, not success", async () => { keychain.jwt = jwtFor(USER); refreshVerificationState.mockResolvedValue(false); - handleSilentIntent({ route: "verified", userId: USER }); + handleUnpromptedIntent({ route: "verified", userId: USER }); await vi.waitFor(() => expect(addToast).toHaveBeenCalled()); const entry = addToast.mock.calls[0][0]; expect(entry.message).toBe("notifications.emailVerification.toast.verifiedPending"); @@ -77,7 +78,7 @@ test("a refresh that leaves the account unverified reports pending, not success" test("a failed refresh reports an error and does not throw", async () => { keychain.jwt = jwtFor(USER); refreshVerificationState.mockRejectedValue(new Error("offline")); - handleSilentIntent({ route: "verified", userId: USER }); + handleUnpromptedIntent({ route: "verified", userId: USER }); await vi.waitFor(() => expect(addToast).toHaveBeenCalled()); expect(addToast.mock.calls[0][0].severity).toBe("error"); }); @@ -86,7 +87,7 @@ test("a link for a saved but inactive account offers a switch instead of acting" keychain.jwt = jwtFor(OTHER_USER); const match = { account_id: "a", mode: "server", email: "other@example.com", jwt: jwtFor(USER) }; getSavedAccounts.mockResolvedValue([match]); - handleSilentIntent({ route: "verified", userId: USER }); + handleUnpromptedIntent({ route: "verified", userId: USER }); await vi.waitFor(() => expect(addToast).toHaveBeenCalled()); expect(refreshVerificationState).not.toHaveBeenCalled(); const entry = addToast.mock.calls[0][0]; @@ -99,7 +100,7 @@ test("a link for a saved but inactive account offers a switch instead of acting" }); test("a link matching no local account only toasts", async () => { - handleSilentIntent({ route: "verified", userId: USER }); + handleUnpromptedIntent({ route: "verified", userId: USER }); await vi.waitFor(() => expect(addToast).toHaveBeenCalled()); expect(refreshVerificationState).not.toHaveBeenCalled(); expect(switchToAccount).not.toHaveBeenCalled(); @@ -109,15 +110,47 @@ test("a link matching no local account only toasts", async () => { test("a malformed stored jwt is skipped rather than throwing", async () => { keychain.jwt = "not-a-jwt"; getSavedAccounts.mockResolvedValue([{ account_id: "a", mode: "server", jwt: "also.not/valid" }]); - handleSilentIntent({ route: "verified", userId: USER }); + handleUnpromptedIntent({ route: "verified", userId: USER }); await vi.waitFor(() => expect(addToast).toHaveBeenCalled()); expect(refreshVerificationState).not.toHaveBeenCalled(); }); test("a rejecting keychain read falls through to the no-match toast rather than rejecting", async () => { invokeImpl = () => Promise.reject(new Error("keychain unavailable")); - handleSilentIntent({ route: "verified", userId: USER }); + handleUnpromptedIntent({ route: "verified", userId: USER }); await vi.waitFor(() => expect(addToast).toHaveBeenCalled()); expect(refreshVerificationState).not.toHaveBeenCalled(); expect(addToast).toHaveBeenCalledTimes(1); }); + +test("a settings link opens the modal on the requested section", () => { + useUIStore.setState({ settingsOpen: false, settingsSection: "appearance" }); + handleUnpromptedIntent({ route: "settings", section: "integrations" }); + const ui = useUIStore.getState(); + expect(ui.settingsOpen).toBe(true); + expect(ui.settingsSection).toBe("integrations"); +}); + +test("a billing link opens the account section and starts no checkout", () => { + useUIStore.setState({ settingsOpen: false, settingsSection: "appearance" }); + handleUnpromptedIntent({ route: "billing" }); + const ui = useUIStore.getState(); + expect(ui.settingsOpen).toBe(true); + expect(ui.settingsSection).toBe("account"); + // The route navigates; a checkout is an action and would need a prompt. + expect(addToast).not.toHaveBeenCalled(); +}); + +test("a notification link opens the centre and carries the entry id", () => { + useUIStore.setState({ notificationCenterOpen: false, notificationFocusId: null }); + handleUnpromptedIntent({ route: "notification", entryId: "invite:42" }); + expect(useUIStore.getState().notificationCenterOpen).toBe(true); + expect(useUIStore.getState().notificationFocusId).toBe("invite:42"); +}); + +test("a notification link without an id still opens the centre", () => { + useUIStore.setState({ notificationCenterOpen: false, notificationFocusId: "stale" }); + handleUnpromptedIntent({ route: "notification", entryId: null }); + expect(useUIStore.getState().notificationCenterOpen).toBe(true); + expect(useUIStore.getState().notificationFocusId).toBeNull(); +}); diff --git a/src/services/deepLinkHandlers.ts b/src/services/deepLinkHandlers.ts index 25b055e71..7ceded2af 100644 --- a/src/services/deepLinkHandlers.ts +++ b/src/services/deepLinkHandlers.ts @@ -2,7 +2,9 @@ import i18n from "@/i18n"; import { refreshVerificationState } from "@/services/account"; import { getJwt } from "@/services/authTokens"; import { getSavedAccounts, switchToAccount, type SavedAccount } from "@/services/savedAccounts"; -import type { SilentIntent, VerifiedIntent } from "@/services/deepLinkUrl"; +import { isNavigateIntent } from "@/services/deepLinkUrl"; +import type { NavigateIntent, UnpromptedIntent, VerifiedIntent } from "@/services/deepLinkUrl"; +import { useUIStore } from "@/stores/uiStore"; import { useNotificationStore } from "@/stores/notificationStore"; import { parseJwtPayload } from "@/utils/emailVerification"; import type { ToastSeverity } from "@/plugins/api"; @@ -67,18 +69,47 @@ async function handleVerified(intent: VerifiedIntent): Promise { } /** - * Silent routes act unprompted, so every failure path here has to end in a - * toast rather than a rejected promise: the caller is an event listener. + * Navigate routes only move the user somewhere they could already go, so none + * of these branches performs an action: `billing` opens the account section + * rather than starting a checkout, and an unknown notification id opens the + * centre on the full list. */ -export function handleSilentIntent(intent: SilentIntent): void { +function handleNavigate(intent: NavigateIntent): void { + const ui = useUIStore.getState(); + switch (intent.route) { + case "notification": + ui.openNotificationCenter(intent.entryId); + return; + case "settings": + ui.openSettings(intent.section); + return; + case "billing": + ui.openSettings("account"); + return; + default: { + const _exhaustive: never = intent; + void _exhaustive; + } + } +} + +/** + * Unprompted routes act without asking, so every failure path here has to end + * in a toast rather than a rejected promise: the caller is an event listener. + */ +export function handleUnpromptedIntent(intent: UnpromptedIntent): void { + // Routed by trust class rather than by a second list of route names, so + // adding a navigate route means touching `TRUST` and `handleNavigate` only. + if (isNavigateIntent(intent)) { + handleNavigate(intent); + return; + } switch (intent.route) { case "verified": void handleVerified(intent); return; - // On the route rather than the intent: TypeScript only narrows the object - // itself to `never` once the union has two or more members. default: { - const _exhaustive: never = intent.route; + const _exhaustive: never = intent; void _exhaustive; } } diff --git a/src/services/deepLinkUrl.test.ts b/src/services/deepLinkUrl.test.ts index d14db2055..8074cc912 100644 --- a/src/services/deepLinkUrl.test.ts +++ b/src/services/deepLinkUrl.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "vitest"; -import { intentKey, isConfirmIntent, isSilentIntent, parseDeepLink, buildDeepLink } from "./deepLinkUrl"; +import { intentKey, isConfirmIntent, isNavigateIntent, isSilentIntent, parseDeepLink, buildDeepLink } from "./deepLinkUrl"; const SESSION = "3f2504e0-4f89-11d3-9a0c-0305e82c3301"; const TOKEN = "deadbeefdeadbeefdeadbeefdeadbeef"; @@ -146,3 +146,68 @@ test("rejects an unknown route in the fragment", () => { test("rejects the http form", () => { expect(parseDeepLink(`http://voltius.app/open#join?s=${SESSION}&t=${TOKEN}`)).toBeNull(); }); + +test("parses a notification link with and without an entry id", () => { + expect(parseDeepLink("voltius://notification?n=invite%3A42")).toEqual({ + route: "notification", + entryId: "invite:42", + }); + expect(parseDeepLink("voltius://notification")).toEqual({ + route: "notification", + entryId: null, + }); +}); + +test("rejects a notification entry id past the length cap", () => { + expect(parseDeepLink(`voltius://notification?n=${"a".repeat(201)}`)).toBeNull(); + expect(parseDeepLink(`voltius://notification?n=${"a".repeat(200)}`)).not.toBeNull(); +}); + +test("parses a settings link only for a section that exists", () => { + expect(parseDeepLink("voltius://settings?section=integrations")).toEqual({ + route: "settings", + section: "integrations", + }); + // `mcp` reads like a section but is a panel inside `integrations`; an id the + // app cannot render has to fail here rather than open an empty modal. + expect(parseDeepLink("voltius://settings?section=mcp")).toBeNull(); + expect(parseDeepLink("voltius://settings?section=__proto__")).toBeNull(); + expect(parseDeepLink("voltius://settings")).toBeNull(); +}); + +test("parses a billing link, which takes no parameters", () => { + expect(parseDeepLink("voltius://billing")).toEqual({ route: "billing" }); + expect(parseDeepLink("voltius://billing?section=account")).toEqual({ route: "billing" }); +}); + +test("the navigate routes are neither confirm nor silent", () => { + for (const url of [ + "voltius://notification", + "voltius://settings?section=account", + "voltius://billing", + ]) { + const intent = parseDeepLink(url)!; + expect(isNavigateIntent(intent)).toBe(true); + expect(isConfirmIntent(intent)).toBe(false); + expect(isSilentIntent(intent)).toBe(false); + } +}); + +test("builds the navigate routes in both forms and round-trips them", () => { + const intents = [ + { route: "notification", entryId: "invite:42" }, + { route: "notification", entryId: null }, + { route: "settings", section: "vaults" }, + { route: "billing" }, + ] as const; + for (const intent of intents) { + for (const form of ["scheme", "https"] as const) { + expect(parseDeepLink(buildDeepLink(intent, form))).toEqual(intent); + } + } +}); + +test("a parameterless route builds without a trailing question mark", () => { + expect(buildDeepLink({ route: "billing" }, "scheme")).toBe("voltius://billing"); + expect(buildDeepLink({ route: "billing" }, "https")).toBe("https://voltius.app/open#billing"); +}); diff --git a/src/services/deepLinkUrl.ts b/src/services/deepLinkUrl.ts index a25394bbe..fa352005b 100644 --- a/src/services/deepLinkUrl.ts +++ b/src/services/deepLinkUrl.ts @@ -1,22 +1,41 @@ import { isSessionId } from "@/services/sessionId"; +import { isSettingsSection, type SettingsSection } from "@/stores/uiStore"; export type JoinIntent = { route: "join"; sessionId: string; token: string }; export type VerifiedIntent = { route: "verified"; userId: string }; -export type DeepLinkIntent = JoinIntent | VerifiedIntent; - -type TrustClass = "confirm" | "silent"; +export type NotificationIntent = { route: "notification"; entryId: string | null }; +export type SettingsIntent = { route: "settings"; section: SettingsSection }; +export type BillingIntent = { route: "billing" }; +export type DeepLinkIntent = + | JoinIntent + | VerifiedIntent + | NotificationIntent + | SettingsIntent + | BillingIntent; + +type TrustClass = "confirm" | "silent" | "navigate"; type Route = DeepLinkIntent["route"]; /** - * The single declaration of trust: `confirm` routes carry a capability and - * nothing happens until the user accepts; `silent` routes carry nothing and act - * unprompted. `verified` is silent because the token died server-side before - * the link was built and the user id authorises nothing, so a hostile link - * costs a session refresh, which is a no-op. + * The single declaration of trust: + * + * - `confirm` routes carry a capability and nothing happens until the user + * accepts. + * - `silent` routes carry no capability and run a side effect unprompted. + * `verified` is silent because the token died server-side before the link was + * built and the user id authorises nothing, so a hostile link costs a session + * refresh, which is a no-op. + * - `navigate` routes only move the user to a screen they could already reach. + * The worst a hostile link achieves is an unexpected panel, so they need no + * prompt — but they must never *act*: `billing` opens the account section and + * deliberately does not start a checkout. */ const TRUST = { join: "confirm", verified: "silent", + notification: "navigate", + settings: "navigate", + billing: "navigate", } as const satisfies Record; type RouteOfClass = { @@ -25,6 +44,9 @@ type RouteOfClass = { export type ConfirmIntent = Extract }>; export type SilentIntent = Extract }>; +export type NavigateIntent = Extract }>; +/** Everything that runs without asking the user first. */ +export type UnpromptedIntent = SilentIntent | NavigateIntent; /** * One codec per route, both directions declared together so the builder and the @@ -35,6 +57,9 @@ type RouteCodec = { params: (intent: Extract) => Record; }; +/** Long enough for any id the inbox builds, short enough to stay a lookup key. */ +const MAX_ENTRY_ID = 200; + const ROUTES: { [K in Route]: RouteCodec } = { join: { parse: (params) => { @@ -53,8 +78,32 @@ const ROUTES: { [K in Route]: RouteCodec } = { }, params: ({ userId }) => ({ u: userId }), }, + notification: { + // The id is opaque here: inbox ids are re-derived from server state on every + // reconcile, so this cannot check one exists. It is length-capped and the + // entry is looked up by exact match, so an unknown id just opens the centre. + parse: (params) => { + const entryId = params.get("n") ?? ""; + if (entryId.length > MAX_ENTRY_ID) return null; + return { route: "notification", entryId: entryId || null }; + }, + params: ({ entryId }): Record => (entryId ? { n: entryId } : {}), + }, + settings: { + parse: (params) => { + const section = params.get("section") ?? ""; + if (!isSettingsSection(section)) return null; + return { route: "settings", section }; + }, + params: ({ section }) => ({ section }), + }, + billing: { + parse: () => ({ route: "billing" }), + params: () => ({}), + }, }; + export type LinkForm = "https" | "scheme"; /** The landing site that bridges an `https` link back to the scheme. */ @@ -73,9 +122,12 @@ export function buildDeepLink(intent: DeepLinkIntent, form: LinkForm = "https"): // construction — TypeScript cannot prove that through the index. const codec = ROUTES[intent.route] as RouteCodec; const query = new URLSearchParams(codec.params(intent)).toString(); + // A parameterless route (`billing`) must not end in a bare `?`: the two forms + // have to round-trip through `parseDeepLink` byte for byte. + const suffix = query ? `?${query}` : ""; return form === "scheme" - ? `voltius://${intent.route}?${query}` - : `${WEB_ORIGIN}${WEB_PATH}#${intent.route}?${query}`; + ? `voltius://${intent.route}${suffix}` + : `${WEB_ORIGIN}${WEB_PATH}#${intent.route}${suffix}`; } const WEB_HOSTS = new Set(["voltius.app", "www.voltius.app"]); @@ -147,3 +199,12 @@ export function isConfirmIntent(intent: DeepLinkIntent): intent is ConfirmIntent export function isSilentIntent(intent: DeepLinkIntent): intent is SilentIntent { return TRUST[intent.route] === "silent"; } + +export function isNavigateIntent(intent: DeepLinkIntent): intent is NavigateIntent { + return TRUST[intent.route] === "navigate"; +} + +/** A route that acts without a prompt, whether it navigates or runs a side effect. */ +export function isUnpromptedIntent(intent: DeepLinkIntent): intent is UnpromptedIntent { + return isSilentIntent(intent) || isNavigateIntent(intent); +} diff --git a/src/stores/deepLinkStore.ts b/src/stores/deepLinkStore.ts index 9458b2689..f9dd53e3a 100644 --- a/src/stores/deepLinkStore.ts +++ b/src/stores/deepLinkStore.ts @@ -2,10 +2,10 @@ import { create } from "zustand"; import { intentKey, isConfirmIntent, - isSilentIntent, + isUnpromptedIntent, type ConfirmIntent, type DeepLinkIntent, - type SilentIntent, + type UnpromptedIntent, } from "@/services/deepLinkUrl"; /** @@ -18,13 +18,13 @@ const MAX_QUEUE = 4; * Kept out of the store's state so a test resetting state cannot leave a stale * handler installed, and so the store never imports the handler module. */ -let silentHandler: ((intent: SilentIntent) => void) | null = null; +let unpromptedHandler: ((intent: UnpromptedIntent) => void) | null = null; // Suppresses only the cold-start echo (getCurrent plus onOpenUrl delivering // the same URL), not a deliberate retry: a failed handler run is not fatal, // so the window must not outlive the user's next click. -let lastSilent: { key: string; at: number } | null = null; -const SILENT_ECHO_WINDOW_MS = 5000; +let lastUnprompted: { key: string; at: number } | null = null; +const UNPROMPTED_ECHO_WINDOW_MS = 5000; interface DeepLinkStore { ready: boolean; @@ -32,7 +32,7 @@ interface DeepLinkStore { prompt: ConfirmIntent | null; setReady(ready: boolean): void; - setSilentHandler(fn: ((intent: SilentIntent) => void) | null): void; + setUnpromptedHandler(fn: ((intent: UnpromptedIntent) => void) | null): void; enqueue(intent: DeepLinkIntent): void; dismissPrompt(): void; } @@ -42,9 +42,9 @@ export const useDeepLinkStore = create((set, get) => ({ queue: [], prompt: null, - setSilentHandler: (fn) => { - silentHandler = fn; - lastSilent = null; + setUnpromptedHandler: (fn) => { + unpromptedHandler = fn; + lastUnprompted = null; }, setReady: (ready) => { @@ -85,13 +85,13 @@ function drain(): void { const { ready, queue, prompt } = useDeepLinkStore.getState(); if (!ready) return; // A confirm intent needs a free prompt slot; everything else is handled - // where it sits, so a silent link never waits behind an open sheet. + // where it sits, so an unprompted link never waits behind an open sheet. const intent = queue.find((queued) => !prompt || !isConfirmIntent(queued)); if (!intent) return; useDeepLinkStore.setState({ queue: queue.filter((queued) => queued !== intent) }); - if (isSilentIntent(intent)) { - dispatchSilent(intent); + if (isUnpromptedIntent(intent)) { + dispatchUnprompted(intent); continue; } // Unknown trust class: drop it, never act on it. @@ -103,11 +103,11 @@ function drain(): void { } } -function dispatchSilent(intent: SilentIntent): void { - if (!silentHandler) return; +function dispatchUnprompted(intent: UnpromptedIntent): void { + if (!unpromptedHandler) return; const key = intentKey(intent); const now = Date.now(); - if (lastSilent?.key === key && now - lastSilent.at < SILENT_ECHO_WINDOW_MS) return; - lastSilent = { key, at: now }; - silentHandler(intent); + if (lastUnprompted?.key === key && now - lastUnprompted.at < UNPROMPTED_ECHO_WINDOW_MS) return; + lastUnprompted = { key, at: now }; + unpromptedHandler(intent); } diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts index f31eb0a55..4e661b7cf 100644 --- a/src/stores/uiStore.ts +++ b/src/stores/uiStore.ts @@ -7,7 +7,17 @@ export type NavItem = "hosts" | "keychain" | "port-forwarding" | "snippets" | "k export type BuiltinRightPanelSection = "snippets" | "history" | "themes" | "ports" | "sftp"; /** Widened to allow plugin-contributed section IDs (prefixed with "plugin:") */ export type RightPanelSection = BuiltinRightPanelSection | (string & {}); -export type SettingsSection = "appearance" | "account" | "sync" | "vaults" | "plugins" | "integrations" | "terminal" | "sftp" | "portForwarding" | "hosts" | "shortcuts" | "diagnostics" | "about"; +/** + * The list, not the union, is the source of truth: a deep link has to check a + * section id it received from outside, and a type alone cannot be checked at + * runtime. + */ +export const SETTINGS_SECTIONS = ["appearance", "account", "sync", "vaults", "plugins", "integrations", "terminal", "sftp", "portForwarding", "hosts", "shortcuts", "diagnostics", "about"] as const; +export type SettingsSection = (typeof SETTINGS_SECTIONS)[number]; + +export function isSettingsSection(value: string): value is SettingsSection { + return (SETTINGS_SECTIONS as readonly string[]).includes(value); +} export type LayoutMode = "grid" | "list"; export type SortMode = "name-asc" | "name-desc" | "newest" | "oldest" | "role-asc"; @@ -73,6 +83,10 @@ interface UIStore { dockedPanelWidth: number; setDockedPanelWidth: (width: number) => void; settingsOpen: boolean; + /** The notification bell's popover. In the store so a deep link can open it. */ + notificationCenterOpen: boolean; + /** Inbox entry a deep link asked for; the bell clears it once shown. */ + notificationFocusId: string | null; cloudAuthOpen: boolean; cloudAuthMode: CloudAuthMode; settingsSection: SettingsSection; @@ -113,6 +127,9 @@ interface UIStore { setActiveNav: (nav: NavItem) => void; setOmniOpen: (open: boolean) => void; setSettingsOpen: (open: boolean) => void; + setNotificationCenterOpen: (open: boolean) => void; + openNotificationCenter: (focusId?: string | null) => void; + clearNotificationFocus: () => void; openCloudAuth: (mode?: CloudAuthMode) => void; closeCloudAuth: () => void; setCloudAuthMode: (mode: CloudAuthMode) => void; @@ -172,6 +189,8 @@ export const useUIStore = create()( globalPanelOpen: {}, dockedPanelWidth: 0, settingsOpen: false, + notificationCenterOpen: false, + notificationFocusId: null as string | null, cloudAuthOpen: false, cloudAuthMode: "signin" as CloudAuthMode, settingsSection: "appearance" as SettingsSection, @@ -218,6 +237,9 @@ export const useUIStore = create()( toggleGlobalPanel: (id) => set((s) => ({ globalPanelOpen: { ...s.globalPanelOpen, [id]: !s.globalPanelOpen[id] } })), setDockedPanelWidth: (width) => set({ dockedPanelWidth: width }), setSettingsOpen: (open) => set((s) => ({ settingsOpen: open, settingsSubPage: open ? s.settingsSubPage : null })), + setNotificationCenterOpen: (open) => set((s) => ({ notificationCenterOpen: open, notificationFocusId: open ? s.notificationFocusId : null })), + openNotificationCenter: (focusId) => set({ notificationCenterOpen: true, notificationFocusId: focusId ?? null }), + clearNotificationFocus: () => set({ notificationFocusId: null }), openCloudAuth: (mode) => set({ cloudAuthOpen: true, cloudAuthMode: mode ?? "signin" }), closeCloudAuth: () => set({ cloudAuthOpen: false }), setCloudAuthMode: (mode) => set({ cloudAuthMode: mode }),