Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions src/components/notifications/NotificationBell.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<NotificationBell />);
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(<NotificationBell />);
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(<NotificationBell />);
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(<NotificationBell />);
act(() => useUIStore.getState().openNotificationCenter("invite:gone"));
expect(screen.getByText("notifications.bell.clearHistory")).toBeTruthy();
expect(useUIStore.getState().notificationFocusId).toBeNull();
});
57 changes: 47 additions & 10 deletions src/components/notifications/NotificationBell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
Expand Down Expand Up @@ -32,6 +33,7 @@ function InboxRow({ entry, onAction }: { entry: InboxEntry; onAction: (i: number
const resolved = entry.state === "resolved";
return (
<div
data-inbox-id={entry.id}
className="flex flex-col gap-1 px-3 py-2.5 rounded-lg"
style={{
background: "var(--t-bg-elevated)",
Expand Down Expand Up @@ -153,11 +155,52 @@ export function NotificationBell() {
const runInboxAction = useNotificationStore((s) => 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<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(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) => {
Expand All @@ -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;
Expand Down Expand Up @@ -224,7 +261,7 @@ export function NotificationBell() {
</button>
</div>

{open && createPortal(
{open && pos && createPortal(
<div
ref={dropdownRef}
style={{
Expand Down
47 changes: 38 additions & 9 deletions src/services/deepLink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const link = (s: string) => `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", () => {
Expand Down Expand Up @@ -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}`);
Expand All @@ -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}`);
Expand All @@ -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);
Expand All @@ -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}`);
Expand All @@ -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);
Expand All @@ -153,18 +153,18 @@ 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);
expect(useDeepLinkStore.getState().queue).toHaveLength(0);
});

// 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();
Expand All @@ -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"]);
});
6 changes: 3 additions & 3 deletions src/services/deepLink.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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))
Expand All @@ -35,7 +35,7 @@ export function startDeepLinks(): () => void {

return () => {
stopped = true;
useDeepLinkStore.getState().setSilentHandler(null);
useDeepLinkStore.getState().setUnpromptedHandler(null);
unlisten?.();
};
}
Loading