= {
@@ -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 }),