diff --git a/.changeset/inbox-arrival-toast-and-desktop-7011.md b/.changeset/inbox-arrival-toast-and-desktop-7011.md
new file mode 100644
index 0000000000..12832cd5f7
--- /dev/null
+++ b/.changeset/inbox-arrival-toast-and-desktop-7011.md
@@ -0,0 +1,51 @@
+---
+'@object-ui/app-shell': minor
+'@object-ui/i18n': minor
+---
+
+Announce inbox messages when they arrive — an in-app toast while the tab is visible, a
+desktop notification while it is hidden (objectui#7011).
+
+The inbox was completely silent about arrivals. `sharedUserFeeds` polls
+`sys_inbox_message` every 10s, the rows landed in the store, the bell badge counted
+them — and a user not staring at the bell learned nothing, so approvals and @-mentions
+were routinely missed. Three candidate popup paths existed and none was connected to the
+inbox: the feed had no diff logic, the console's sonner bridge only serves notifications
+that explicitly declare `displayType: 'toast'`, and there was no `new Notification(` call
+anywhere in `packages/` or `apps/`.
+
+**Presentation layer only.** The transport is untouched: the same two reads, the same
+10s / 60s cadence, the same backoff, and no push channel. The accepted consequence is
+that a backgrounded tab can be up to a minute late — speeding the poll up to shave that
+would trade a server-wide cost for one surface's latency.
+
+What arrives:
+
+- **`useInboxArrivalNotifier`**, mounted from `useInboxBell` — the one wiring of the
+ shared feed onto a bell — so the header bell and the `global:notifications` page block
+ announce by the same rules and through the same `markRead`.
+- **`inboxArrivals`**, the pure diff: a session-scoped seen set, `(topic, title)`
+ collapse reused from the inbox's own `groupNotifications`, and a bounded memory.
+- **`desktopNotifications`**, the single door to the browser Notification API.
+- **Two switches** in the account menu's Preferences section, stored per user in
+ localStorage: in-app alerts (on by default) and desktop notifications (off).
+
+Four rules decide when nothing happens, and they matter more than the positive case — an
+announcer that pops for everything is worse than the silence it replaces, because users
+switch it off and then miss the approvals too:
+
+- the FIRST answered read primes the seen set and announces nothing, so historical unread
+ at login or after a refresh updates the badge only;
+- several rows in one cycle announce once, collapsed by `(topic, title)`;
+- a row that already carries a read receipt never announces;
+- a hidden tab gets the desktop notification and no toast; a visible tab gets the toast
+ and no desktop notification.
+
+**`Notification.requestPermission()` is called from the settings toggle's change handler
+and from nowhere else** — never on mount, on a feed refresh, or on a first message. A
+browser answers that prompt once and `denied` is permanent for the origin, so a
+load-time request spends the channel for every user who reflexively blocks, and no later
+release can undo it. A browser that has not granted permission behaves exactly as it did
+before this change: completely silent.
+
+Seven `notifications.*` keys are added to all ten locale packs.
diff --git a/apps/console/inbox-arrival-preview.html b/apps/console/inbox-arrival-preview.html
new file mode 100644
index 0000000000..e3f5e2c707
--- /dev/null
+++ b/apps/console/inbox-arrival-preview.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Inbox Arrival Preview
+
+
+
+
+
+
+
diff --git a/apps/console/src/inbox-arrival-preview.tsx b/apps/console/src/inbox-arrival-preview.tsx
new file mode 100644
index 0000000000..7480672094
--- /dev/null
+++ b/apps/console/src/inbox-arrival-preview.tsx
@@ -0,0 +1,258 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * Inbox-arrival preview — the REAL-BROWSER fixture for objectui#7011.
+ *
+ * Dev-server only, exactly like the sibling `*-preview.html` pages: the console's
+ * production build takes `index.html` as its single rollup input, so nothing
+ * here ships.
+ *
+ * ## Why this exists when the feature already has unit pins
+ *
+ * The two APIs this feature turns on are the two happy-dom does not have:
+ * `Notification` is absent outright, and `document.visibilityState` is a
+ * prototype getter a test can only fake. Unit pins therefore measure a
+ * SIMULATION of both, and the failure mode of a simulation is the worst one
+ * available here — a suite that reports "no desktop notification was raised"
+ * for a run in which raising one was never possible. This page puts the real
+ * modules in a real Chromium, with a real permission grant, so the claim is
+ * measured rather than modelled.
+ *
+ * It is the "test instance kept as a fixture" the card asks for. The driver
+ * that steers it lives beside it at `scripts/inbox-arrival-browser-check.mjs`.
+ *
+ * ## What it mounts
+ *
+ * The real `useInboxArrivalNotifier`, the real `presentNotificationToast` (via
+ * the real `ConsoleToaster`), and the real `NotificationPreferencesMenu`. The
+ * only thing faked is the FEED: rows are pushed in from the page's own controls
+ * (or from `window.__inboxArrivalFixture`), because the transport is out of
+ * this card's scope and a real poll would need a backend.
+ */
+
+import { StrictMode, useEffect, useMemo, useRef, useState } from 'react';
+import { createRoot } from 'react-dom/client';
+import { MemoryRouter, useLocation } from 'react-router-dom';
+import { toast } from 'sonner';
+import { I18nProvider } from '@object-ui/i18n';
+import { ConsoleToaster, ThemeProvider } from '@object-ui/app-shell';
+// ⚠️ Workspace SOURCE paths below, NOT `@object-ui//` specifiers.
+//
+// Neither `@object-ui/app-shell` nor `@object-ui/auth` publishes a subpath —
+// `.` is their whole `exports` map — and none of the six bindings is on either
+// barrel: three are app-shell internals, two are their types, and
+// `__resetInboxArrivals` is a TEST SEAM that must not become public API just so
+// a fixture can mount. Written as subpath specifiers they LOOKED fine, because
+// a Vite string alias matches by PREFIX: `@object-ui/app-shell/hooks/x` went
+// through the `@object-ui/app-shell` -> `packages/app-shell/src` alias in
+// `vite.config.ts`, so the dev server and the browser check were both green,
+// while `tsc` resolved the same specifier through the package's `exports` map,
+// found no subpath, and failed the console build with six TS2307s — taking the
+// `Bundle Analysis` job down before it measured a single chunk.
+//
+// These paths are exactly what that alias already resolved to, so the modules
+// loaded at runtime are unchanged and both packages publish exactly what they
+// published before. Dev-server-only fixture: the production build takes
+// `index.html` as its single input, so none of this ships.
+//
+// The auth CONTEXT itself, not the provider: the provider signs in against a
+// real server, and this fixture only needs a stable user id to scope the
+// session.
+import { AuthCtx, type AuthContextValue } from '../../../packages/auth/src/AuthContext';
+import { useInboxArrivalNotifier } from '../../../packages/app-shell/src/hooks/useInboxArrivalNotifier';
+import { __resetInboxArrivals } from '../../../packages/app-shell/src/hooks/inboxArrivals';
+import { NotificationPreferencesMenu } from '../../../packages/app-shell/src/layout/NotificationPreferencesMenu';
+import type { InboxNotification } from '../../../packages/app-shell/src/layout/inboxGrouping';
+import type { SharedFeedStatus } from '../../../packages/app-shell/src/hooks/sharedUserFeeds';
+import './index.css';
+
+const FIXTURE_USER = 'u_fixture_alice';
+
+const auth = {
+ user: { id: FIXTURE_USER, name: 'Ada Lovelace', email: 'ada@example.com' },
+ session: null,
+ isAuthenticated: true,
+ isAuthEnabled: true,
+ isLoading: false,
+ error: null,
+ isPreviewMode: false,
+ previewMode: null,
+} as unknown as AuthContextValue;
+
+/** A `sys_inbox_message` row as `mergeInboxRows` produces it. */
+function row(id: string, over: Partial = {}): InboxNotification {
+ return {
+ id,
+ notification_id: `ntf_${id}`,
+ receipt_id: null,
+ type: 'collab.assignment',
+ title: `Assigned to you: ${id}`,
+ body: 'Ship the arrival notifier',
+ action_url: `/showcase_task/${id}`,
+ is_read: false,
+ created_at: new Date().toISOString(),
+ ...over,
+ };
+}
+
+declare global {
+ interface Window {
+ __inboxArrivalFixture?: {
+ reset(): void;
+ setStatus(status: SharedFeedStatus): void;
+ setRows(rows: InboxNotification[]): void;
+ row: typeof row;
+ log: string[];
+ storedPreferences(): string | null;
+ };
+ }
+}
+
+function LocationLog({ onNavigate }: { onNavigate: (path: string) => void }) {
+ const location = useLocation();
+ // Latest-ref: the caller passes an inline arrow, and depending on its
+ // identity would re-log the SAME location on every unrelated re-render.
+ // Written from an effect, not during render — a ref updated mid-render is
+ // exactly what `react-hooks/refs` flags, and the same latest-ref shape the
+ // notifier itself uses.
+ const sink = useRef(onNavigate);
+ useEffect(() => {
+ sink.current = onNavigate;
+ });
+ useEffect(() => {
+ sink.current(`${location.pathname}${location.search}`);
+ }, [location.pathname, location.search]);
+ return null;
+}
+
+function Fixture() {
+ const [rows, setRows] = useState([]);
+ const [status, setStatus] = useState('loading');
+ const [log, setLog] = useState([]);
+
+ const markRead = useMemo(
+ () => (id: string) => setLog((entries) => [...entries, `markRead:${id}`]),
+ [],
+ );
+
+ useInboxArrivalNotifier({ notifications: rows, status, markRead });
+
+ useEffect(() => {
+ window.__inboxArrivalFixture = {
+ reset: () => {
+ __resetInboxArrivals();
+ // Toasts outlive a scenario otherwise (4s auto-dismiss), and the next
+ // scenario would count the previous one's.
+ toast.dismiss();
+ setRows([]);
+ setStatus('loading');
+ setLog([]);
+ },
+ setStatus,
+ setRows,
+ row,
+ log,
+ storedPreferences: () => window.localStorage.getItem(
+ `objectui.notificationPreferences:u:${FIXTURE_USER}`,
+ ),
+ };
+ }, [log]);
+
+ return (
+ <>
+ setLog((entries) => [...entries, `navigate:${path}`])} />
+
+
Inbox arrival fixture (objectui#7011)
+
+ Drives the real arrival notifier with a scripted feed. The transport is
+ deliberately absent — this page is about what the presentation layer does
+ with rows, not about how they got here.
+
+
+
+
+
+
+
+
+
+
+
+ Preferences (as they appear in the account menu)
+
+
+
+
+
+
Feed status
+
{status}
+
Rows
+
{rows.map((r) => r.id).join(', ') || '(none)'}
+
Activity log
+
{log.join('\n') || '(empty)'}
+
+
+
+ >
+ );
+}
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+
+ {/*
+ * The Router wraps the fixture rather than living inside it: the
+ * notifier calls `useNavigate`, so it has to be BELOW a router, and
+ * a component that renders its own is still above it.
+ * The real console has the same shape — AppHeader mounts inside the
+ * app's router — and this fixture caught the difference.
+ */}
+
+
+
+
+
+
+ ,
+);
diff --git a/apps/console/tsconfig.json b/apps/console/tsconfig.json
index ea59b4dfd0..6cefd07275 100644
--- a/apps/console/tsconfig.json
+++ b/apps/console/tsconfig.json
@@ -22,7 +22,16 @@
"noFallthroughCasesInSwitch": true,
/* Testing */
- "types": ["vitest/globals", "@testing-library/jest-dom"]
+ // `node` is here for the DEV-ONLY preview fixtures under `src/`, which
+ // import workspace package SOURCES by relative path rather than through a
+ // package barrel (see the note in `src/inbox-arrival-preview.tsx` for why a
+ // `@object-ui//` specifier is not available to them). Those
+ // sources are written against their own package's tsconfig, which declares
+ // `types: ["node", "vite/client"]`, and they guard on `typeof process` for
+ // the non-browser case — without `node` here that guard is TS2591 and the
+ // console build fails on another package's legitimate code. Additive only:
+ // it declares globals, it suppresses no check.
+ "types": ["vitest/globals", "@testing-library/jest-dom", "node"]
},
"include": ["src", "dev"],
"references": [{ "path": "./tsconfig.node.json" }]
diff --git a/packages/app-shell/src/__tests__/spec-symbol-parity.test.ts b/packages/app-shell/src/__tests__/spec-symbol-parity.test.ts
index 547d4b6e40..aa85daf624 100644
--- a/packages/app-shell/src/__tests__/spec-symbol-parity.test.ts
+++ b/packages/app-shell/src/__tests__/spec-symbol-parity.test.ts
@@ -22,6 +22,15 @@
* the other name, while its `FieldGroup` is the Studio field-editor's group
* config. Renaming to the spec's own name was the fix.
*
+ * Later collisions are APPENDED to `RENAMES` under their own card rather than
+ * folded into the census above: twenty-eight / twenty / eight is a measurement
+ * of the batch-3 burn-down at the time it was taken, and re-counting it here
+ * whenever a new symbol arrives would make it unreproducible.
+ * `BrowserNotificationPreferences` (objectui#7011) is the first such arrival —
+ * `hooks/notificationPreferences.ts` holds the two per-browser announcement
+ * switches, which is a different layer from the spec's account-level delivery
+ * routing; the measurement that decided rename-over-derive is in that file.
+ *
* A rename only stays a fix for as long as the new name is genuinely free. If
* the spec later ships a `FlowDesignerNode`, this package would quietly be back
* where it started — a local declaration under a spec export's name, read by
@@ -165,6 +174,11 @@ const RENAMES: Array<[local: string, formerly: string, specMeaning: string]> = [
['FlowDesignerEdge', 'FlowEdge', 'a COMPLETE authored flow edge (id required, condition needs `dialect`)'],
['PackageManifestRow', 'PackageManifest', 'the full authored package manifest (~40 keys)'],
['InstalledPackageRow', 'InstalledPackage', 'the full install record (installedAt, upgradeHistory, …)'],
+ [
+ 'BrowserNotificationPreferences',
+ 'NotificationPreferences',
+ "the ACCOUNT's server-persisted delivery routing — `email` / `push` / `inApp` / `digest` / `channels`, moved by the `getNotificationPreferences` API pair",
+ ],
];
/**
diff --git a/packages/app-shell/src/hooks/__tests__/inboxArrivals.test.ts b/packages/app-shell/src/hooks/__tests__/inboxArrivals.test.ts
new file mode 100644
index 0000000000..0ea1d89f4d
--- /dev/null
+++ b/packages/app-shell/src/hooks/__tests__/inboxArrivals.test.ts
@@ -0,0 +1,197 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * objectui#7011 — which inbox rows are ARRIVALS, and what one announcement says.
+ *
+ * ## Why the NEGATIVE cases are the load-bearing ones here
+ *
+ * A suite that only asserted "a new message produces an arrival" is passed by an
+ * implementation that announces EVERY row it ever sees — history at login,
+ * already-read rows, the same rows again next poll. That implementation is
+ * materially WORSE than the silence the card was raised about: ten toasts on
+ * every refresh is how a user learns to switch notifications off, and once they
+ * have, the approvals and @-mentions this feature exists for are missed too.
+ *
+ * So the first fetch not announcing, and an already-read row not announcing,
+ * are pinned at least as hard as the positive case — and the caricature
+ * (a selector that answers the same thing for every input) is pinned in BOTH
+ * directions: `announce everything` and `announce nothing` each go red below.
+ */
+import { describe, it, expect, beforeEach } from 'vitest';
+import {
+ claimInboxArrivals,
+ digestArrivals,
+ inboxArrivalMemory,
+ rememberSeen,
+ SEEN_MESSAGE_LIMIT,
+ __resetInboxArrivals,
+} from '../inboxArrivals';
+import type { InboxNotification } from '../../layout/inboxGrouping';
+
+/** A row as `mergeInboxRows` produces it. Ids are CONCRETE and distinguishable. */
+function row(id: string, over: Partial = {}): InboxNotification {
+ return {
+ id,
+ notification_id: `ntf_${id}`,
+ receipt_id: null,
+ type: 'collab.assignment',
+ title: `Assigned to you: ${id}`,
+ body: null,
+ action_url: `/showcase_task/${id}`,
+ is_read: false,
+ created_at: '2026-09-08T10:00:00Z',
+ ...over,
+ };
+}
+
+const USER = 'u_alice';
+
+beforeEach(() => {
+ __resetInboxArrivals();
+});
+
+describe('claimInboxArrivals — the first answered read primes, it does not announce', () => {
+ it('announces NOTHING for the historical unread the first read brings back', () => {
+ const history = [row('m3'), row('m2'), row('m1')];
+
+ const arrivals = claimInboxArrivals(USER, history);
+
+ expect(arrivals).toEqual([]);
+ // ...and it primed, rather than simply having seen nothing: the difference
+ // matters, because "primed with zero rows" and "never scanned" behave
+ // identically on the next call only if the memory really was written.
+ expect(inboxArrivalMemory().key).toBe(USER);
+ expect([...inboxArrivalMemory().seen]).toEqual(['m3', 'm2', 'm1']);
+ });
+
+ it('announces the row that arrives AFTER the priming read, and only that row', () => {
+ claimInboxArrivals(USER, [row('m1')]);
+
+ const arrivals = claimInboxArrivals(USER, [row('m2'), row('m1')]);
+
+ // Concrete ids, so this cannot be satisfied by "returned something".
+ expect(arrivals.map((a) => a.id)).toEqual(['m2']);
+ });
+
+ it('does not re-announce a row it has already announced', () => {
+ claimInboxArrivals(USER, [row('m1')]);
+ expect(claimInboxArrivals(USER, [row('m2'), row('m1')]).map((a) => a.id)).toEqual(['m2']);
+
+ // The same window again — the poll's normal steady state.
+ expect(claimInboxArrivals(USER, [row('m2'), row('m1')])).toEqual([]);
+ });
+
+ it('re-primes for a DIFFERENT signed-in user instead of announcing their inbox', () => {
+ claimInboxArrivals(USER, [row('m1')]);
+
+ // A second account on the same browser: their unread is history to them.
+ const arrivals = claimInboxArrivals('u_bob', [row('m9'), row('m8')]);
+
+ expect(arrivals).toEqual([]);
+ expect(inboxArrivalMemory().key).toBe('u_bob');
+ });
+});
+
+describe('claimInboxArrivals — only UNREAD rows announce', () => {
+ it('ignores a newly-seen row that already carries a read receipt', () => {
+ claimInboxArrivals(USER, [row('m1')]);
+
+ const arrivals = claimInboxArrivals(USER, [row('m2', { is_read: true }), row('m1')]);
+
+ expect(arrivals).toEqual([]);
+ });
+
+ it('still REMEMBERS the read row, so it cannot announce later if it flips', () => {
+ claimInboxArrivals(USER, [row('m1')]);
+ claimInboxArrivals(USER, [row('m2', { is_read: true }), row('m1')]);
+
+ // A poll where the receipt has not come back yet must not resurrect it.
+ expect(claimInboxArrivals(USER, [row('m2'), row('m1')])).toEqual([]);
+ });
+
+ it('announces the unread ones and drops the read ones from the SAME cycle', () => {
+ claimInboxArrivals(USER, [row('m1')]);
+
+ const arrivals = claimInboxArrivals(USER, [
+ row('m4'),
+ row('m3', { is_read: true }),
+ row('m2'),
+ row('m1'),
+ ]);
+
+ expect(arrivals.map((a) => a.id)).toEqual(['m4', 'm2']);
+ });
+});
+
+describe('claimInboxArrivals — a claim is a claim: the second consumer of one snapshot gets nothing', () => {
+ it('hands the arrivals to the first scanner only', () => {
+ claimInboxArrivals(USER, [row('m1')]);
+ const snapshot = [row('m2'), row('m1')];
+
+ // The header bell and the `global:notifications` block, same commit.
+ const first = claimInboxArrivals(USER, snapshot);
+ const second = claimInboxArrivals(USER, snapshot);
+
+ expect(first.map((a) => a.id)).toEqual(['m2']);
+ expect(second).toEqual([]);
+ });
+});
+
+describe('rememberSeen — bounded, and it never lets a windowed row age out', () => {
+ it('keeps the newest ids and drops the oldest past the limit', () => {
+ const previous = Array.from({ length: SEEN_MESSAGE_LIMIT }, (_, i) => `old_${i}`);
+
+ const next = rememberSeen(previous, ['fresh_1', 'fresh_2']);
+
+ expect(next).toHaveLength(SEEN_MESSAGE_LIMIT);
+ expect(next.slice(-2)).toEqual(['fresh_1', 'fresh_2']);
+ expect(next).not.toContain('old_0');
+ expect(next).not.toContain('old_1');
+ });
+
+ it('moves a still-windowed id to the young end rather than leaving it to age out', () => {
+ // `Set.add` on an existing member does NOT reorder — that is the exact bug
+ // this function exists to make unrepresentable, so it is pinned by ORDER.
+ const next = rememberSeen(['a', 'b', 'c'], ['b']);
+
+ expect(next).toEqual(['a', 'c', 'b']);
+ });
+
+ it('survives a window larger than the limit without losing the newest rows', () => {
+ const window = Array.from({ length: 4 }, (_, i) => `w_${i}`);
+
+ expect(rememberSeen(['old'], window, 2)).toEqual(['w_2', 'w_3']);
+ });
+});
+
+describe('digestArrivals — one cycle says one thing, collapsed by the inbox\'s own rule', () => {
+ it('is null when nothing arrived', () => {
+ expect(digestArrivals([])).toBeNull();
+ });
+
+ it('collapses repeats of one (topic, title) into ONE group', () => {
+ const digest = digestArrivals([
+ row('m3', { type: 'project.digest', title: 'Scheduled project digest' }),
+ row('m2', { type: 'project.digest', title: 'Scheduled project digest' }),
+ row('m1', { type: 'project.digest', title: 'Scheduled project digest' }),
+ ]);
+
+ expect(digest).not.toBeNull();
+ expect(digest!.count).toBe(3);
+ expect(digest!.groups).toHaveLength(1);
+ expect(digest!.groups[0].items).toHaveLength(3);
+ // The newest row is the click target — the one a user would open.
+ expect(digest!.target.id).toBe('m3');
+ });
+
+ it('keeps genuinely different messages as different groups', () => {
+ const digest = digestArrivals([
+ row('m2', { type: 'approval.requested', title: 'Approval needed: PO-88' }),
+ row('m1', { type: 'collab.mention', title: 'Ada mentioned you' }),
+ ]);
+
+ expect(digest!.groups.map((g) => g.type)).toEqual(['approval.requested', 'collab.mention']);
+ expect(digest!.count).toBe(2);
+ });
+});
diff --git a/packages/app-shell/src/hooks/__tests__/useInboxArrivalNotifier.test.tsx b/packages/app-shell/src/hooks/__tests__/useInboxArrivalNotifier.test.tsx
new file mode 100644
index 0000000000..21ffe3d3b4
--- /dev/null
+++ b/packages/app-shell/src/hooks/__tests__/useInboxArrivalNotifier.test.tsx
@@ -0,0 +1,558 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * objectui#7011 — the inbox announces arrivals, and announces nothing else.
+ *
+ * ## Harness: two APIs that happy-dom does not hand you, and the lit controls
+ *
+ * Both of this feature's decisions read a browser API that is not simply there
+ * in this environment, and a test that silently no-ops because the API is
+ * absent is the classic false green for exactly this feature — it would report
+ * "no desktop notification was raised" for a run in which raising one was never
+ * possible.
+ *
+ * - **`Notification`** does not exist in happy-dom at all. It is stubbed with
+ * {@link FakeNotification} through `vi.stubGlobal`, which is why
+ * `desktopNotifications.ts` reads `globalThis.Notification` at CALL time
+ * rather than capturing it at module load. The stub is LIT by
+ * `it('the Notification stub is live', …)` below — it constructs one through
+ * the module under test and asserts the instance arrived — so every later
+ * "no notification was raised" reading is a measurement rather than an
+ * absence.
+ * - **`document.visibilityState`** is a getter on the prototype and is not
+ * assignable. {@link setVisibility} redefines it as an own property and
+ * asserts the new reading before returning, so a case can never proceed on a
+ * visibility it failed to set. The original descriptor is restored per case.
+ *
+ * ## Why the negative pins are the discriminating ones
+ *
+ * An implementation that toasts every row it sees passes "a new message
+ * produces a toast" and is worse than the silence this card was raised for. The
+ * pins that reject it are the ones below that assert NOTHING happened: history
+ * on the first read, an already-read row, a hidden tab, an ungranted browser.
+ * Each is written against concrete, distinguishable ids and permissions so it
+ * cannot pass by comparing two absent values.
+ */
+import '@testing-library/jest-dom/vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
+import React from 'react';
+import { MemoryRouter } from 'react-router-dom';
+
+/**
+ * `vi.mock` factories are hoisted above every `const` in this file, so anything
+ * a factory dereferences at module-load time must be hoisted with them —
+ * otherwise the mocked module is evaluated while these bindings are still in
+ * their temporal dead zone and the whole SUITE fails to load (which vitest
+ * reports as `0 test`, not as a failing assertion).
+ */
+const { navigate, presentNotificationToast, requestPermission } = vi.hoisted(() => ({
+ navigate: vi.fn(),
+ presentNotificationToast: vi.fn(),
+ requestPermission: vi.fn(),
+}));
+
+vi.mock('react-router-dom', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useNavigate: () => navigate,
+}));
+
+vi.mock('@object-ui/auth', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useAuth: () => ({ user: { id: 'u_alice', name: 'Ada', email: 'ada@example.com' } }),
+}));
+
+/**
+ * The toast is observed at the bridge rather than in the DOM. That module is,
+ * by its own contract, "the ONLY place a notification becomes a sonner call",
+ * so a call to it IS the toast — and observing it here is what lets the
+ * "one toast, not N" case count calls instead of counting DOM nodes, where a
+ * `queryByText` would throw on multiple matches precisely when the assertion
+ * is most interesting.
+ */
+vi.mock('../../chrome/notificationToast', () => ({ presentNotificationToast }));
+
+import { useInboxArrivalNotifier } from '../useInboxArrivalNotifier';
+import { __resetInboxArrivals } from '../inboxArrivals';
+import {
+ writeNotificationPreferences,
+ NOTIFICATION_PREFERENCES_KEY,
+ __resetNotificationPreferences,
+} from '../notificationPreferences';
+import { requestDesktopNotificationPermission } from '../desktopNotifications';
+import { NotificationPreferencesMenu } from '../../layout/NotificationPreferencesMenu';
+import type { InboxNotification } from '../../layout/inboxGrouping';
+import type { SharedFeedStatus } from '../sharedUserFeeds';
+
+// ── Notification API stub ────────────────────────────────────────────────────
+
+interface RaisedNotification {
+ title: string;
+ options?: Record;
+ onclick: ((event: unknown) => unknown) | null;
+ close: ReturnType;
+}
+
+let raised: RaisedNotification[] = [];
+
+class FakeNotification {
+ static permission: 'granted' | 'denied' | 'default' = 'default';
+ static requestPermission = requestPermission;
+ onclick: ((event: unknown) => unknown) | null = null;
+ close = vi.fn();
+ constructor(title: string, options?: Record) {
+ raised.push(this as unknown as RaisedNotification);
+ (this as unknown as RaisedNotification).title = title;
+ (this as unknown as RaisedNotification).options = options;
+ }
+}
+
+// ── document.visibilityState ─────────────────────────────────────────────────
+
+const ORIGINAL_VISIBILITY = Object.getOwnPropertyDescriptor(
+ Document.prototype,
+ 'visibilityState',
+);
+
+/** Set the tab's visibility and PROVE it took, before the case relies on it. */
+function setVisibility(state: 'visible' | 'hidden'): void {
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: () => state,
+ });
+ // The lit control for this instrument: an unsettable getter would leave the
+ // case measuring the default, and every visibility assertion after it would
+ // be about a tab that never changed.
+ expect(document.visibilityState).toBe(state);
+}
+
+function restoreVisibility(): void {
+ Reflect.deleteProperty(document, 'visibilityState');
+ if (ORIGINAL_VISIBILITY && !Object.getOwnPropertyDescriptor(document, 'visibilityState')) return;
+ if (ORIGINAL_VISIBILITY) Object.defineProperty(Document.prototype, 'visibilityState', ORIGINAL_VISIBILITY);
+}
+
+// ── Rows ─────────────────────────────────────────────────────────────────────
+
+function row(id: string, over: Partial = {}): InboxNotification {
+ return {
+ id,
+ notification_id: `ntf_${id}`,
+ receipt_id: null,
+ type: 'collab.assignment',
+ title: `Assigned to you: ${id}`,
+ body: null,
+ action_url: `/showcase_task/${id}`,
+ is_read: false,
+ created_at: '2026-09-08T10:00:00Z',
+ ...over,
+ };
+}
+
+const markRead = vi.fn();
+
+const wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+interface Props {
+ notifications: InboxNotification[];
+ status: SharedFeedStatus;
+}
+
+function mountNotifier(initial: Props) {
+ return renderHook(
+ (props: Props) => useInboxArrivalNotifier({ ...props, markRead }),
+ { initialProps: initial, wrapper },
+ );
+}
+
+/** Let the effects flush. No fake timers anywhere in this file — see below. */
+const settle = async () => { await act(async () => { await Promise.resolve(); }); };
+
+beforeEach(() => {
+ __resetInboxArrivals();
+ raised = [];
+ navigate.mockClear();
+ markRead.mockClear();
+ presentNotificationToast.mockClear();
+ requestPermission.mockClear();
+ requestPermission.mockImplementation(async () => FakeNotification.permission);
+ FakeNotification.permission = 'default';
+ vi.stubGlobal('Notification', FakeNotification);
+ window.localStorage.clear();
+ // The preference store is module-scoped (one live value per tab), so a case
+ // would otherwise inherit the previous case's switches.
+ __resetNotificationPreferences();
+ setVisibility('visible');
+});
+
+afterEach(() => {
+ // The feed is not mounted here (the hook takes rows as arguments), so nothing
+ // can poll after a case returns and there are no fake timers to restore —
+ // which is the point of giving this hook its rows rather than a feed.
+ vi.unstubAllGlobals();
+ restoreVisibility();
+ window.localStorage.clear();
+});
+
+describe('instrument controls — both stubbed APIs are live', () => {
+ it('the Notification stub is live: the module under test can raise one', async () => {
+ FakeNotification.permission = 'granted';
+ writeNotificationPreferences('u_alice', { toast: true, desktop: true });
+ setVisibility('hidden');
+
+ const view = mountNotifier({ notifications: [], status: 'ready' });
+ await settle();
+ view.rerender({ notifications: [row('m1')], status: 'ready' });
+ await settle();
+
+ // If this is 0, every "nothing was raised" case below is meaningless.
+ expect(raised).toHaveLength(1);
+ expect(raised[0].title).toBe('Assigned to you: m1');
+ });
+
+ it('the requestPermission spy is wired to the module the settings toggle calls', async () => {
+ FakeNotification.permission = 'default';
+ await requestDesktopNotificationPermission();
+ // Lit: the same spy the "never requested on load" pins assert stays at 0.
+ expect(requestPermission).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('constraint 1 — historical unread updates the badge, it does not pop', () => {
+ it('announces nothing for the rows the FIRST answered read brings back', async () => {
+ const history = [row('m3'), row('m2'), row('m1')];
+
+ mountNotifier({ notifications: history, status: 'ready' });
+ await settle();
+
+ expect(presentNotificationToast).not.toHaveBeenCalled();
+ expect(raised).toHaveLength(0);
+ });
+
+ it('still announces the NEXT message, so priming is not silence forever', async () => {
+ const view = mountNotifier({ notifications: [row('m1')], status: 'ready' });
+ await settle();
+ expect(presentNotificationToast).not.toHaveBeenCalled();
+
+ view.rerender({ notifications: [row('m2'), row('m1')], status: 'ready' });
+ await settle();
+
+ expect(presentNotificationToast).toHaveBeenCalledTimes(1);
+ expect(presentNotificationToast.mock.calls[0][0]).toMatchObject({
+ title: 'Assigned to you: m2',
+ });
+ });
+
+ it('does not prime off a NON-answer: a loading snapshot is not this cycle\'s rows', async () => {
+ // The store keeps the last value through `loading` / `error`. Priming off
+ // one would either announce the whole inbox when the real answer lands, or
+ // (worse) swallow it. Only `ready` may be scanned.
+ const view = mountNotifier({ notifications: [row('m1')], status: 'loading' });
+ await settle();
+
+ view.rerender({ notifications: [row('m1')], status: 'ready' });
+ await settle();
+
+ // The `ready` snapshot is the FIRST scan, so it primes: no announcement.
+ expect(presentNotificationToast).not.toHaveBeenCalled();
+
+ view.rerender({ notifications: [row('m2'), row('m1')], status: 'ready' });
+ await settle();
+ expect(presentNotificationToast).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('constraint 2 — several messages in one cycle announce once', () => {
+ it('raises ONE toast for three rows arriving together, not three', async () => {
+ const view = mountNotifier({ notifications: [], status: 'ready' });
+ await settle();
+
+ view.rerender({
+ notifications: [
+ row('m4', { type: 'approval.requested', title: 'Approval needed: PO-88' }),
+ row('m3', { type: 'collab.mention', title: 'Ada mentioned you' }),
+ row('m2'),
+ ],
+ status: 'ready',
+ });
+ await settle();
+
+ expect(presentNotificationToast).toHaveBeenCalledTimes(1);
+ expect(presentNotificationToast.mock.calls[0][0].title).toContain('3');
+ });
+
+ it('reuses the inbox (topic, title) collapse: one repeated topic is ONE thing', async () => {
+ const digestRow = (id: string) =>
+ row(id, { type: 'project.digest', title: 'Scheduled project digest' });
+ const view = mountNotifier({ notifications: [], status: 'ready' });
+ await settle();
+
+ view.rerender({
+ notifications: [digestRow('m3'), digestRow('m2'), digestRow('m1')],
+ status: 'ready',
+ });
+ await settle();
+
+ expect(presentNotificationToast).toHaveBeenCalledTimes(1);
+ // Not "3 new messages" — the bell would show these as one collapsed group.
+ expect(presentNotificationToast.mock.calls[0][0].title).toBe('Scheduled project digest');
+ });
+});
+
+describe('constraint 5 — toast and desktop notification are mutually exclusive', () => {
+ it('a VISIBLE tab gets the toast and NO system notification', async () => {
+ FakeNotification.permission = 'granted';
+ writeNotificationPreferences('u_alice', { toast: true, desktop: true });
+ setVisibility('visible');
+
+ const view = mountNotifier({ notifications: [], status: 'ready' });
+ await settle();
+ view.rerender({ notifications: [row('m1')], status: 'ready' });
+ await settle();
+
+ expect(presentNotificationToast).toHaveBeenCalledTimes(1);
+ // The lit control above proved this counter can reach 1 in this file.
+ expect(raised).toHaveLength(0);
+ });
+
+ it('a HIDDEN tab gets the system notification and NO toast', async () => {
+ FakeNotification.permission = 'granted';
+ writeNotificationPreferences('u_alice', { toast: true, desktop: true });
+ setVisibility('hidden');
+
+ const view = mountNotifier({ notifications: [], status: 'ready' });
+ await settle();
+ view.rerender({ notifications: [row('m1')], status: 'ready' });
+ await settle();
+
+ expect(raised).toHaveLength(1);
+ expect(presentNotificationToast).not.toHaveBeenCalled();
+ });
+});
+
+describe('acceptance 4 — an ungranted browser is completely silent, exactly as before', () => {
+ it('raises nothing when the user opted in but the browser has not granted', async () => {
+ FakeNotification.permission = 'default';
+ writeNotificationPreferences('u_alice', { toast: true, desktop: true });
+ setVisibility('hidden');
+
+ const view = mountNotifier({ notifications: [], status: 'ready' });
+ await settle();
+ view.rerender({ notifications: [row('m1')], status: 'ready' });
+ await settle();
+
+ expect(raised).toHaveLength(0);
+ expect(presentNotificationToast).not.toHaveBeenCalled();
+ });
+
+ it('raises nothing when the browser granted but the user has not opted in', async () => {
+ FakeNotification.permission = 'granted';
+ // The shipped default: desktop off.
+ setVisibility('hidden');
+
+ const view = mountNotifier({ notifications: [], status: 'ready' });
+ await settle();
+ view.rerender({ notifications: [row('m1')], status: 'ready' });
+ await settle();
+
+ expect(raised).toHaveLength(0);
+ });
+});
+
+describe('⭐ the non-regression axis — permission is NEVER requested by the presenter', () => {
+ it('does not request on mount, on a feed refresh, or on the first message', async () => {
+ FakeNotification.permission = 'default';
+ writeNotificationPreferences('u_alice', { toast: true, desktop: true });
+ setVisibility('hidden');
+
+ // Mount.
+ const view = mountNotifier({ notifications: [], status: 'loading' });
+ await settle();
+ expect(requestPermission).not.toHaveBeenCalled();
+
+ // The feed answers (a refresh).
+ view.rerender({ notifications: [row('m1')], status: 'ready' });
+ await settle();
+ expect(requestPermission).not.toHaveBeenCalled();
+
+ // The first message actually arrives — the moment a lazy implementation
+ // would be tempted to ask, because asking here would "make it work".
+ view.rerender({ notifications: [row('m2'), row('m1')], status: 'ready' });
+ await settle();
+ expect(requestPermission).not.toHaveBeenCalled();
+
+ // And again while VISIBLE, so the toast path is covered by the same claim.
+ setVisibility('visible');
+ view.rerender({ notifications: [row('m3'), row('m2'), row('m1')], status: 'ready' });
+ await settle();
+ expect(requestPermission).not.toHaveBeenCalled();
+ });
+});
+
+describe('acceptance 1 — clicking the announcement opens the message and marks it read', () => {
+ it('the toast action marks the row read and deep-links to it', async () => {
+ const view = mountNotifier({ notifications: [], status: 'ready' });
+ await settle();
+ view.rerender({ notifications: [row('m1')], status: 'ready' });
+ await settle();
+
+ const item = presentNotificationToast.mock.calls[0][0];
+ act(() => { item.actions[0].onClick(); });
+
+ expect(markRead).toHaveBeenCalledWith('m1');
+ // No apps in this harness's metadata, so the host segment resolves to the
+ // setup app — the assertion is that the row's `action_url` was resolved
+ // through `resolveNotificationTarget`, not hand-built.
+ expect(navigate).toHaveBeenCalledWith('/apps/setup/showcase_task/m1');
+ });
+
+ it('a row with NO action_url opens the full inbox instead of navigating nowhere', async () => {
+ const view = mountNotifier({ notifications: [], status: 'ready' });
+ await settle();
+ view.rerender({ notifications: [row('m1', { action_url: null })], status: 'ready' });
+ await settle();
+
+ act(() => { presentNotificationToast.mock.calls[0][0].actions[0].onClick(); });
+
+ expect(markRead).toHaveBeenCalledWith('m1');
+ expect(navigate).toHaveBeenCalledWith('/apps/setup/sys_inbox_message?view=mine');
+ });
+
+ it('clicking the system notification focuses the window, then opens the message', async () => {
+ FakeNotification.permission = 'granted';
+ writeNotificationPreferences('u_alice', { toast: true, desktop: true });
+ setVisibility('hidden');
+ const focus = vi.fn();
+ vi.stubGlobal('focus', focus);
+
+ const view = mountNotifier({ notifications: [], status: 'ready' });
+ await settle();
+ view.rerender({ notifications: [row('m1')], status: 'ready' });
+ await settle();
+
+ expect(raised).toHaveLength(1);
+ act(() => { raised[0].onclick?.(new Event('click')); });
+
+ expect(focus).toHaveBeenCalled();
+ expect(markRead).toHaveBeenCalledWith('m1');
+ expect(navigate).toHaveBeenCalledWith('/apps/setup/showcase_task/m1');
+ expect(raised[0].close).toHaveBeenCalled();
+ });
+});
+
+describe('the switches govern the announcement, not the memory', () => {
+ it('announces nothing while in-app alerts are off', async () => {
+ writeNotificationPreferences('u_alice', { toast: false, desktop: false });
+
+ const view = mountNotifier({ notifications: [], status: 'ready' });
+ await settle();
+ view.rerender({ notifications: [row('m1')], status: 'ready' });
+ await settle();
+
+ expect(presentNotificationToast).not.toHaveBeenCalled();
+ });
+
+ it('does not replay the backlog when the switch is turned back on', async () => {
+ writeNotificationPreferences('u_alice', { toast: false, desktop: false });
+ const view = mountNotifier({ notifications: [], status: 'ready' });
+ await settle();
+ view.rerender({ notifications: [row('m2'), row('m1')], status: 'ready' });
+ await settle();
+
+ // The user switches alerts on. The rows they missed were SEEN by the
+ // session — they were simply not announced — so nothing replays.
+ writeNotificationPreferences('u_alice', { toast: true, desktop: false });
+ window.dispatchEvent(
+ new StorageEvent('storage', {
+ key: `${NOTIFICATION_PREFERENCES_KEY}:u:u_alice`,
+ newValue: JSON.stringify({ toast: true, desktop: false }),
+ storageArea: window.localStorage,
+ }),
+ );
+ await settle();
+ view.rerender({ notifications: [row('m2'), row('m1')], status: 'ready' });
+ await settle();
+
+ expect(presentNotificationToast).not.toHaveBeenCalled();
+
+ // ...but the NEXT message is announced, so this is not "off forever".
+ view.rerender({ notifications: [row('m3'), row('m2'), row('m1')], status: 'ready' });
+ await settle();
+ expect(presentNotificationToast).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('⭐ flipping a switch reaches the presenter in the SAME tab', () => {
+ /**
+ * The regression this pins was found in a real browser, not here, and it made
+ * the desktop half of the feature completely inert: the settings menu and the
+ * notifier each held their own `useState` copy of the preferences, so the menu
+ * flipped its copy and wrote localStorage while the presenter kept believing
+ * `desktop: false` until the page was reloaded. `useStorageSync` cannot cover
+ * it — the `storage` event fires only in OTHER tabs, by design — so the two
+ * surfaces now read ONE module-scoped store.
+ *
+ * Written as one tree with both surfaces in it, because a hook mounted alone
+ * cannot disagree with itself and that is exactly why the unit pins missed it.
+ */
+ function NotifierProbe({ notifications, status }: Props) {
+ useInboxArrivalNotifier({ notifications, status, markRead });
+ return null;
+ }
+
+ function Both(props: Props) {
+ return (
+
+
+
+
+ );
+ }
+
+ it('turning desktop notifications on makes the very next hidden-tab arrival announce', async () => {
+ FakeNotification.permission = 'granted';
+ setVisibility('hidden');
+
+ const view = render();
+ await settle();
+
+ // Before the switch: the shipped default is off, so the tab is silent.
+ view.rerender();
+ await settle();
+ expect(raised).toHaveLength(0);
+
+ // The user turns it on. No reload, no remount of the presenter.
+ fireEvent.click(screen.getByTestId('notification-desktop-toggle'));
+ await waitFor(() =>
+ expect(screen.getByTestId('notification-desktop-toggle')).toHaveAttribute('data-state', 'checked'),
+ );
+
+ view.rerender();
+ await settle();
+
+ expect(raised.map((n) => n.title)).toEqual(['Assigned to you: m2']);
+ });
+
+ it('turning in-app alerts off silences the very next visible-tab arrival', async () => {
+ setVisibility('visible');
+ const view = render();
+ await settle();
+
+ view.rerender();
+ await settle();
+ expect(presentNotificationToast).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(screen.getByTestId('notification-toast-toggle'));
+ await waitFor(() =>
+ expect(screen.getByTestId('notification-toast-toggle')).toHaveAttribute('data-state', 'unchecked'),
+ );
+
+ view.rerender();
+ await settle();
+
+ expect(presentNotificationToast).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/app-shell/src/hooks/desktopNotifications.ts b/packages/app-shell/src/hooks/desktopNotifications.ts
new file mode 100644
index 0000000000..505d376cde
--- /dev/null
+++ b/packages/app-shell/src/hooks/desktopNotifications.ts
@@ -0,0 +1,165 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * desktopNotifications — the console's ONLY door to the browser Notification
+ * API (objectui#7011).
+ *
+ * Before this module the repo made no `new Notification(...)` and no
+ * `Notification.requestPermission()` call anywhere, which is why a backgrounded
+ * tab could not be told anything at all. One door rather than call sites is
+ * what makes the two rules below checkable instead of merely intended.
+ *
+ * ## Rule 1 — permission is requested from a user gesture, never on load
+ *
+ * {@link requestDesktopNotificationPermission} is the only function here that
+ * prompts, and the only caller it is wired to is the settings toggle's own
+ * change handler. Asking on page load is the standard antipattern for this API,
+ * and the cost of getting it wrong is not a bad first impression: a `denied`
+ * verdict is PERMANENT for the origin as far as the page is concerned — the
+ * browser will not ask again, and no later release can undo it. There is no
+ * recovery path in code, only "go and change it in browser settings", which
+ * almost nobody does. So the channel is lost for that user forever.
+ *
+ * `no-restricted-globals`-style enforcement is not available for a member
+ * access like `Notification.requestPermission`, so the pin that guards this
+ * lives in the suite instead (`useInboxArrivalNotifier.permission.test.tsx`):
+ * it mounts, refreshes the feed and delivers a first message, and asserts the
+ * request was never issued.
+ *
+ * ## Rule 2 — the API is read at CALL time, never captured at module load
+ *
+ * `globalThis.Notification` is absent in happy-dom (and in any SSR pass), and a
+ * module-level `const Ctor = globalThis.Notification` would freeze that absence
+ * into the module for the life of the process — which in a test run means a
+ * stubbed API installed by a case would never be seen, and every assertion
+ * about the desktop path would pass by doing nothing. Every function here
+ * therefore goes through {@link notificationApi}.
+ *
+ * @module
+ */
+
+/** The permission verdicts the API can report, plus "no API here at all". */
+export type DesktopNotificationPermission = 'granted' | 'denied' | 'default' | 'unsupported';
+
+/** The slice of the Notification API this module uses. */
+interface NotificationApi {
+ permission: 'granted' | 'denied' | 'default';
+ requestPermission?: () => Promise<'granted' | 'denied' | 'default'>;
+ new (title: string, options?: Record): {
+ onclick: ((this: unknown, ev: unknown) => unknown) | null;
+ close?: () => void;
+ };
+}
+
+/**
+ * The live API, or `null` where there is none.
+ *
+ * Read fresh every time — see the module header. `typeof` rather than a
+ * property probe because `globalThis` is not guaranteed to exist on every
+ * target this package is built for.
+ */
+function notificationApi(): NotificationApi | null {
+ if (typeof globalThis === 'undefined') return null;
+ const api = (globalThis as { Notification?: unknown }).Notification;
+ return typeof api === 'function' ? (api as unknown as NotificationApi) : null;
+}
+
+/** Whether this browser offers desktop notifications at all. */
+export function isDesktopNotificationSupported(): boolean {
+ return notificationApi() !== null;
+}
+
+/**
+ * The current verdict, without asking for anything.
+ *
+ * Safe to call on load, and the settings UI does: showing the toggle greyed out
+ * with "blocked in your browser settings" needs to READ the verdict, and
+ * reading it is not requesting it.
+ */
+export function desktopNotificationPermission(): DesktopNotificationPermission {
+ const api = notificationApi();
+ if (!api) return 'unsupported';
+ const permission = api.permission;
+ return permission === 'granted' || permission === 'denied' ? permission : 'default';
+}
+
+/**
+ * Prompt the user for permission.
+ *
+ * ⛔ Call this from a user gesture and from nowhere else. Never from an effect,
+ * a mount, a feed refresh or a message arrival — see the module header for what
+ * a denial costs. Today the single call site is the "Desktop notifications"
+ * toggle in the account menu.
+ *
+ * An already-settled verdict is returned without prompting: browsers ignore a
+ * second request on a `denied` origin anyway, and re-asking a `granted` one is
+ * pure noise.
+ */
+export async function requestDesktopNotificationPermission(): Promise {
+ const api = notificationApi();
+ if (!api) return 'unsupported';
+ if (api.permission === 'granted' || api.permission === 'denied') return api.permission;
+ if (typeof api.requestPermission !== 'function') return desktopNotificationPermission();
+ try {
+ const verdict = await api.requestPermission();
+ return verdict === 'granted' || verdict === 'denied' ? verdict : 'default';
+ } catch {
+ // A browser that refuses the call (an insecure origin, an iframe without
+ // the permission) has not granted anything, and must not be reported as if
+ // it had — the caller writes the preference off this answer.
+ return desktopNotificationPermission();
+ }
+}
+
+export interface DesktopNotificationRequest {
+ title: string;
+ body?: string;
+ /**
+ * Collapse key. Two notifications sharing a tag replace one another in the
+ * OS tray rather than stacking, which is the desktop half of "one cycle
+ * announces once" — a user who was away for several cycles comes back to one
+ * entry per topic, not a wall.
+ */
+ tag?: string;
+ /** Run when the user clicks the system notification. */
+ onActivate?: () => void;
+}
+
+/**
+ * Show one system notification. Returns whether one was actually shown, so a
+ * caller (and a pin) can tell "shown" from "silently not shown".
+ *
+ * Refuses unless permission is already `granted`: this function never prompts,
+ * so a `default` verdict here means the user has not opted in and the correct
+ * behaviour is the pre-#7011 one — silence.
+ */
+export function showDesktopNotification(request: DesktopNotificationRequest): boolean {
+ const api = notificationApi();
+ if (!api || api.permission !== 'granted') return false;
+ try {
+ const notification = new api(request.title, {
+ ...(request.body ? { body: request.body } : {}),
+ ...(request.tag ? { tag: request.tag } : {}),
+ });
+ notification.onclick = () => {
+ // Bring the tab forward first: the deep link is useless in a window the
+ // user cannot see, and this is the one thing a system notification can do
+ // that an in-page toast never needs to.
+ (globalThis as { focus?: () => void }).focus?.();
+ request.onActivate?.();
+ notification.close?.();
+ };
+ return true;
+ } catch {
+ // Some engines throw for a notification raised outside a service worker.
+ // A failed announcement is not worth an error to the user — the badge and
+ // the bell still carry the message.
+ return false;
+ }
+}
diff --git a/packages/app-shell/src/hooks/inboxArrivals.ts b/packages/app-shell/src/hooks/inboxArrivals.ts
new file mode 100644
index 0000000000..e2f6bd7a0d
--- /dev/null
+++ b/packages/app-shell/src/hooks/inboxArrivals.ts
@@ -0,0 +1,176 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * inboxArrivals — which inbox rows are NEW ARRIVALS worth announcing, and what
+ * one announcement says about them (objectui#7011).
+ *
+ * The inbox feed (`sharedUserFeeds`) polls `sys_inbox_message` and writes the
+ * rows into a store; until this module existed nothing turned "the store grew a
+ * row" into anything a user could perceive. Announcing is the presentation
+ * layer's job, and it is entirely a question about DIFFS — which is why the
+ * decision lives here as a pure module rather than inline in the hook that
+ * presents: the three rules that decide it are the three ways the feature gets
+ * user-hostile, and each one is worth a test that can fail on its own.
+ *
+ * 1. **The first answered read never announces.** Historical unread at login
+ * or after a refresh is not an arrival — it is the state of the inbox. A
+ * presenter that pops for it fires ten toasts on every page refresh, which
+ * is the fastest way to get notifications switched off for good, after
+ * which the approvals and @-mentions the feature exists for are missed too.
+ * So the first `ready` snapshot for a session identity PRIMES the seen set
+ * and returns nothing.
+ * 2. **Only unread rows announce.** A row that arrives already carrying a
+ * read receipt has been consumed somewhere else (another tab, the record
+ * page, `mark all read`); announcing it re-raises something the user has
+ * already dealt with.
+ * 3. **One cycle announces once.** Several rows landing in one poll collapse
+ * into a single announcement, and they collapse by the inbox's OWN
+ * `(topic, title)` rule (`groupNotifications`) rather than a second rule
+ * invented here — the bell already answers "how many distinct things is
+ * this really?" that way, and two answers to that question would drift.
+ *
+ * ## Why the seen set is module-scoped rather than a ref
+ *
+ * `useInboxBell` is mounted by BOTH the header bell and the `global:notifications`
+ * page block, and a page may mount both at once. A per-hook ref would make each
+ * mount its own announcer: two toasts for one message, and a route change that
+ * remounts the header would re-prime and re-announce everything on screen.
+ * A module-scoped set makes the dedupe STRUCTURAL, the same reasoning
+ * `sharedUserFeeds` records for its own store: whichever consumer scans first
+ * takes the arrivals, every later consumer in the same cycle finds them already
+ * seen, and the set survives remounts because it belongs to the SESSION, not to
+ * a component instance.
+ *
+ * @module
+ */
+
+import { groupNotifications, type InboxNotification, type NotificationGroup } from '../layout/inboxGrouping.js';
+
+/**
+ * How many message ids the session remembers.
+ *
+ * The feed's window is 20 rows, so this is 25 windows of headroom — an id can
+ * only age out long after it has left the window, and rows still IN the window
+ * are re-appended on every scan (see {@link rememberSeen}), so an aged-out id
+ * can never be one the next poll could show again. Bounded because a session
+ * left open for a day at a 10 s cadence would otherwise grow the set forever.
+ */
+export const SEEN_MESSAGE_LIMIT = 500;
+
+/** Stable empty result — an effect that re-runs must not see a fresh array. */
+const NO_ARRIVALS: readonly InboxNotification[] = Object.freeze([]);
+
+/**
+ * The session's announcement memory.
+ *
+ * `key` is the session identity the seen ids belong to (the signed-in user id).
+ * A different key means a different person is looking at this browser, so their
+ * unread is history to them too: re-prime rather than announce.
+ */
+const memory: { key: string | null; seen: string[] } = { key: null, seen: [] };
+
+/**
+ * Remember `current`, keeping the set bounded and keeping every currently
+ * windowed id at the YOUNG end.
+ *
+ * Order matters and `Set.add` would get it wrong: adding an id that is already
+ * present does not move it, so a long-lived row could age out of a
+ * front-trimmed set while still being in the feed's window — and would then
+ * announce itself a second time. Rebuilding as "older ids that are not in the
+ * window, then the whole window" makes that unrepresentable.
+ */
+export function rememberSeen(
+ previous: readonly string[],
+ current: readonly string[],
+ limit: number = SEEN_MESSAGE_LIMIT,
+): string[] {
+ const inWindow = new Set(current);
+ const merged = [...previous.filter((id) => !inWindow.has(id)), ...current];
+ return merged.length > limit ? merged.slice(merged.length - limit) : merged;
+}
+
+/**
+ * Take this cycle's arrivals and mark every row seen.
+ *
+ * Claiming is a MUTATION on purpose: the caller that scans first owns the
+ * announcement, and a second consumer scanning the same snapshot is handed
+ * nothing. It is also why the claim happens BEFORE the caller checks whether
+ * announcements are switched on — a user who enables toasts mid-session must
+ * not be greeted by every message that arrived while they were off.
+ *
+ * @param key Session identity the memory belongs to — the signed-in user id.
+ * @param rows The feed's current rows, newest first. Only pass rows from a
+ * snapshot whose status is `ready`: a `loading` or `error`
+ * snapshot carries the LAST value, and treating that as this
+ * cycle's answer would prime the memory off a stale read.
+ */
+export function claimInboxArrivals(
+ key: string,
+ rows: readonly InboxNotification[],
+): readonly InboxNotification[] {
+ const ids = rows.map((row) => row.id);
+
+ if (key !== memory.key) {
+ // First answered read for this identity: this IS the inbox, not an event.
+ memory.key = key;
+ memory.seen = rememberSeen([], ids);
+ return NO_ARRIVALS;
+ }
+
+ const known = new Set(memory.seen);
+ // Unseen AND unread — the card's own definition of what arrived. A row that
+ // is new to this session but already read was consumed elsewhere.
+ const arrivals = rows.filter((row) => !known.has(row.id) && !row.is_read);
+ memory.seen = rememberSeen(memory.seen, ids);
+ return arrivals.length > 0 ? arrivals : NO_ARRIVALS;
+}
+
+/**
+ * What the session currently remembers — a read-only view for pins, so a test
+ * can distinguish "primed and announced nothing" from "never scanned".
+ */
+export function inboxArrivalMemory(): { key: string | null; seen: readonly string[] } {
+ return { key: memory.key, seen: memory.seen };
+}
+
+/** Test seam — forget the session, so cases do not inherit each other's scans. */
+export function __resetInboxArrivals(): void {
+ memory.key = null;
+ memory.seen = [];
+}
+
+/** One cycle's arrivals, reduced to what a single announcement needs. */
+export interface ArrivalDigest {
+ /** Newest arrival — the row an announcement navigates to and marks read. */
+ target: InboxNotification;
+ /** How many rows arrived in this cycle. */
+ count: number;
+ /**
+ * The arrivals under the inbox's own `(topic, title)` collapse. One group is
+ * one real thing to say; several groups are several, and the announcement
+ * summarizes rather than picking a winner.
+ */
+ groups: NotificationGroup[];
+}
+
+/**
+ * Reduce a cycle's arrivals to one digest, or `null` when nothing arrived.
+ *
+ * Rows arrive newest-first (the feed orders `created_at desc`), so the target
+ * is simply the first — the message the user would look at if they only looked
+ * at one.
+ */
+export function digestArrivals(arrivals: readonly InboxNotification[]): ArrivalDigest | null {
+ if (arrivals.length === 0) return null;
+ return {
+ target: arrivals[0],
+ count: arrivals.length,
+ groups: groupNotifications([...arrivals]),
+ };
+}
diff --git a/packages/app-shell/src/hooks/notificationPreferences.ts b/packages/app-shell/src/hooks/notificationPreferences.ts
new file mode 100644
index 0000000000..8741a122bc
--- /dev/null
+++ b/packages/app-shell/src/hooks/notificationPreferences.ts
@@ -0,0 +1,276 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * notificationPreferences — the two switches that govern inbox announcements
+ * (objectui#7011).
+ *
+ * Deliberately localStorage-only for this iteration. A server-side preference
+ * object is real work with its own surface (a settings namespace, a manifest, a
+ * migration) and the card scopes it out explicitly; what it buys — the same
+ * answer on a second device — is also the thing these two settings care least
+ * about, because both describe THIS browser: whether toasts may cover this
+ * screen, and whether this browser's notification permission should be used.
+ *
+ * Storage is scoped per user id (`scopedKey`) for the same reason every other
+ * local preference in this package is: two accounts on one browser must not
+ * inherit each other's choices.
+ *
+ * ## Defaults, and why they differ
+ *
+ * - **Toast: on.** It is in-page, it costs nothing to refuse, and a silent
+ * inbox is the defect the card was raised for. A feature that ships off by
+ * default fixes nothing for the users who never find the switch.
+ * - **Desktop: off.** Turning it on PROMPTS for browser permission, and a
+ * prompt nobody asked for is answered "block" often enough that shipping it
+ * on by default would spend the channel on the user's behalf, permanently.
+ * Off is also exactly today's behaviour, so a user who touches nothing is not
+ * surprised by their operating system.
+ *
+ * @module
+ */
+
+import { useCallback, useMemo, useSyncExternalStore } from 'react';
+import { useAuth } from '@object-ui/auth';
+import { scopedKey, useStorageSync } from '../context/UserStateAdapters.js';
+import {
+ desktopNotificationPermission,
+ isDesktopNotificationSupported,
+ requestDesktopNotificationPermission,
+ type DesktopNotificationPermission,
+} from './desktopNotifications.js';
+
+/** localStorage key base; `scopedKey` appends `:u:` when signed in. */
+export const NOTIFICATION_PREFERENCES_KEY = 'objectui.notificationPreferences';
+
+/**
+ * The two switches, and why the name is not the plain one.
+ *
+ * ⚠️ This is NOT the `NotificationPreferences` that `@objectstack/spec/api`
+ * publishes, and the name says so on purpose — a local declaration under a spec
+ * export's name is read by the next agent as the spec's own definition
+ * (`check:spec-symbols`, objectstack#4115). Measured against the installed
+ * `@objectstack/spec` 17.3.0, the two are different layers under one word:
+ *
+ * - The spec's is the ACCOUNT's server-persisted delivery routing — which
+ * transports a notification is sent over and how often — carried by the
+ * `getNotificationPreferences` / `updateNotificationPreferences` API pair.
+ * Its keys: `email`, `push`, `inApp`, `digest`, `channels`.
+ * - This one is THIS BROWSER's presentation of a row that has already been
+ * delivered: may a toast cover this screen, and may this browser's
+ * Notification API be used. Its keys: `toast`, `desktop`.
+ *
+ * Zero keys in common, and the direction that settles it is not the key count
+ * but the parse: the spec's schema strips both of these
+ * (`NotificationPreferencesSchema.parse({ toast: true, desktop: false })`
+ * returns `{ email: true, push: true, inApp: true, digest: 'none' }`), so
+ * importing or deriving the spec's type cannot express these two switches at
+ * all. Binding to it would change what this feature stores, not merely what the
+ * type is called — so the doctrine's preferred arm (import/derive) is not
+ * available here and this is a renamed dialect instead.
+ *
+ * The tripwire that keeps the new name genuinely free lives in
+ * `src/__tests__/spec-symbol-parity.test.ts`; if the spec ever publishes
+ * `BrowserNotificationPreferences`, that test fails rather than this file
+ * quietly re-creating the collision under the new name.
+ *
+ * The server-persisted object is out of objectui#7011's scope. If it ever
+ * arrives here it is the spec's shape under the spec's name, imported, sitting
+ * beside this one rather than replacing it.
+ */
+export interface BrowserNotificationPreferences {
+ /** In-page toast when a message arrives and the tab is visible. */
+ toast: boolean;
+ /** System notification when a message arrives and the tab is hidden. */
+ desktop: boolean;
+}
+
+export const DEFAULT_NOTIFICATION_PREFERENCES: BrowserNotificationPreferences = Object.freeze({
+ toast: true,
+ desktop: false,
+});
+
+/**
+ * Parse a stored value into preferences.
+ *
+ * Every member is defaulted individually rather than the object being accepted
+ * or rejected whole: a stored blob written before a member existed is a normal
+ * state, and dropping the user's other choice because of it would be a
+ * regression they never asked for.
+ */
+export function parseNotificationPreferences(raw: unknown): BrowserNotificationPreferences {
+ const value = raw as Partial | null | undefined;
+ return {
+ toast: typeof value?.toast === 'boolean' ? value.toast : DEFAULT_NOTIFICATION_PREFERENCES.toast,
+ desktop: typeof value?.desktop === 'boolean' ? value.desktop : DEFAULT_NOTIFICATION_PREFERENCES.desktop,
+ };
+}
+
+/** Read the stored preferences for a user, defaulting on anything unusable. */
+export function readNotificationPreferences(userId?: string | null): BrowserNotificationPreferences {
+ if (typeof window === 'undefined') return DEFAULT_NOTIFICATION_PREFERENCES;
+ try {
+ const raw = window.localStorage.getItem(scopedKey(NOTIFICATION_PREFERENCES_KEY, userId));
+ return parseNotificationPreferences(raw ? JSON.parse(raw) : null);
+ } catch {
+ // A disabled/full/parse-hostile store costs the preference, never the page.
+ return DEFAULT_NOTIFICATION_PREFERENCES;
+ }
+}
+
+/** Persist preferences for a user. Best-effort — storage may be unavailable. */
+export function writeNotificationPreferences(
+ userId: string | null | undefined,
+ preferences: BrowserNotificationPreferences,
+): void {
+ if (typeof window === 'undefined') return;
+ try {
+ window.localStorage.setItem(
+ scopedKey(NOTIFICATION_PREFERENCES_KEY, userId),
+ JSON.stringify(preferences),
+ );
+ } catch {
+ /* best-effort */
+ }
+}
+
+/**
+ * ONE live value per storage key, shared by every hook instance in this tab.
+ *
+ * ## Why a store and not `useState` in the hook (measured in a real browser)
+ *
+ * Two surfaces call {@link useNotificationPreferences}: the settings menu that
+ * WRITES, and the arrival notifier that READS. With per-hook `useState` those
+ * are two independent copies of the same fact — the menu flipped its own copy
+ * and wrote localStorage, and the presenter kept the value it had read at
+ * mount. `useStorageSync` did not cover it either: the `storage` event fires
+ * only in OTHER tabs, by design, so it is exactly the same-tab case that was
+ * missed. Symptom: switching desktop notifications on had no effect at all
+ * until the page was reloaded — the switch said `granted`, the presenter still
+ * believed `desktop: false`, and the tab stayed silent. Found by the browser
+ * fixture (`apps/console/src/inbox-arrival-preview.tsx`), not by the unit pins,
+ * because a unit pin mounts one hook instance.
+ *
+ * The snapshot is cached per key so `useSyncExternalStore` gets a STABLE
+ * reference — handing back a fresh object per call re-renders forever (the same
+ * rule `sharedUserFeeds` records for its own store).
+ */
+const listeners = new Set<() => void>();
+let cache: { key: string; value: BrowserNotificationPreferences } | null = null;
+
+function snapshot(key: string, userId: string | null | undefined): BrowserNotificationPreferences {
+ if (!cache || cache.key !== key) cache = { key, value: readNotificationPreferences(userId) };
+ return cache.value;
+}
+
+function publish(key: string, value: BrowserNotificationPreferences): void {
+ cache = { key, value };
+ for (const listener of [...listeners]) listener();
+}
+
+function subscribe(onStoreChange: () => void): () => void {
+ listeners.add(onStoreChange);
+ return () => { listeners.delete(onStoreChange); };
+}
+
+/** Test seam — drop the cached value so cases do not inherit each other's. */
+export function __resetNotificationPreferences(): void {
+ cache = null;
+ for (const listener of [...listeners]) listener();
+}
+
+export interface NotificationPreferencesController {
+ preferences: BrowserNotificationPreferences;
+ /** The browser's current verdict, READ (never requested) on every render. */
+ desktopPermission: DesktopNotificationPermission;
+ /** Whether this browser has a Notification API at all. */
+ desktopSupported: boolean;
+ setToastEnabled: (enabled: boolean) => void;
+ /**
+ * Turn desktop notifications on. ⭐ This is the ONLY path in the console that
+ * reaches `Notification.requestPermission()`, and it is reachable only from
+ * the toggle's change handler — see `desktopNotifications.ts` for why that
+ * matters permanently.
+ *
+ * The preference is written from the VERDICT, not from the intent: a user who
+ * flips the switch and then blocks the prompt gets the switch back off, which
+ * is the truth (nothing will be delivered) rather than a switch that claims a
+ * channel it does not have.
+ */
+ enableDesktop: () => Promise;
+ disableDesktop: () => void;
+}
+
+/**
+ * The two switches, live: current values, the browser's permission verdict, and
+ * the only sanctioned way to change either.
+ */
+export function useNotificationPreferences(): NotificationPreferencesController {
+ const { user } = useAuth();
+ const userId = user?.id ?? null;
+ const key = scopedKey(NOTIFICATION_PREFERENCES_KEY, userId);
+
+ // A different key (a different signed-in user) re-reads: the previous
+ // account's choices live under a different key and are not this one's.
+ const preferences = useSyncExternalStore(
+ subscribe,
+ () => snapshot(key, userId),
+ () => snapshot(key, userId),
+ );
+
+ // Another tab flipping a switch flips it here too — one browser, one answer.
+ useStorageSync>(key, (value) => {
+ publish(key, parseNotificationPreferences(value));
+ });
+
+ /**
+ * Read the verdict on every render rather than caching it: the user can
+ * change it in browser chrome at any moment, and a stale `denied` would leave
+ * the toggle greyed out after they fixed it. Reading is free and never
+ * prompts.
+ */
+ const desktopPermission = desktopNotificationPermission();
+ const desktopSupported = isDesktopNotificationSupported();
+
+ const persist = useCallback(
+ (next: BrowserNotificationPreferences) => {
+ writeNotificationPreferences(userId, next);
+ publish(scopedKey(NOTIFICATION_PREFERENCES_KEY, userId), next);
+ },
+ [userId],
+ );
+
+ const setToastEnabled = useCallback(
+ (enabled: boolean) => {
+ persist({ ...readNotificationPreferences(userId), toast: enabled });
+ },
+ [persist, userId],
+ );
+
+ const enableDesktop = useCallback(async (): Promise => {
+ const verdict = await requestDesktopNotificationPermission();
+ persist({ ...readNotificationPreferences(userId), desktop: verdict === 'granted' });
+ return verdict;
+ }, [persist, userId]);
+
+ const disableDesktop = useCallback(() => {
+ persist({ ...readNotificationPreferences(userId), desktop: false });
+ }, [persist, userId]);
+
+ return useMemo(
+ () => ({
+ preferences,
+ desktopPermission,
+ desktopSupported,
+ setToastEnabled,
+ enableDesktop,
+ disableDesktop,
+ }),
+ [preferences, desktopPermission, desktopSupported, setToastEnabled, enableDesktop, disableDesktop],
+ );
+}
diff --git a/packages/app-shell/src/hooks/useInboxArrivalNotifier.ts b/packages/app-shell/src/hooks/useInboxArrivalNotifier.ts
new file mode 100644
index 0000000000..ce43f0a856
--- /dev/null
+++ b/packages/app-shell/src/hooks/useInboxArrivalNotifier.ts
@@ -0,0 +1,204 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * useInboxArrivalNotifier — turn a new inbox row into something the user
+ * actually perceives (objectui#7011).
+ *
+ * The inbox has been silent since it was built: `sharedUserFeeds` polls
+ * `sys_inbox_message`, the rows land in the store, the bell badge counts them,
+ * and a user not staring at the bell learns nothing. This hook is the whole of
+ * the announcement, and it is mounted from `useInboxBell` — the one wiring of
+ * the shared feed onto a bell — so every surface that shows the inbox
+ * announces it, and none of them announces it twice (the seen set that decides
+ * is module-scoped; see `inboxArrivals.ts`).
+ *
+ * ## What it deliberately is NOT
+ *
+ * Presentation only. Nothing here touches the transport: no cadence is changed,
+ * no request is added, and no push channel (WebSocket / SSE) is opened — those
+ * are framework-side platform work with their own project. The consequence is
+ * accepted rather than worked around: a backgrounded tab polls at 60 s, so a
+ * desktop notification can be up to about a minute late. Shaving that by
+ * speeding the poll up would trade a server-wide cost for one surface's
+ * latency, which is the trade the card refused.
+ *
+ * ## The two surfaces are mutually exclusive, by the tab's visibility
+ *
+ * A visible tab gets the in-page toast; a hidden one gets the system
+ * notification and no toast. They are not two channels a user might get both
+ * of: a toast fired into a hidden tab is a toast that expires unseen, and a
+ * system notification raised over a tab the user is looking at is an OS-level
+ * interruption for something already on their screen.
+ *
+ * @module
+ */
+
+import { useCallback, useEffect, useRef } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import { useAuth } from '@object-ui/auth';
+import { useObjectTranslation } from '@object-ui/i18n';
+import { presentNotificationToast } from '../chrome/notificationToast.js';
+import { useMetadata } from '../providers/MetadataProvider.js';
+import { useNavigationContext } from '../context/NavigationContext.js';
+import { resolveHostAppSegment, resolveNotificationTarget } from '../utils/appRoute.js';
+import type { InboxNotification } from '../layout/inboxGrouping.js';
+import type { SharedFeedStatus } from './sharedUserFeeds.js';
+import { claimInboxArrivals, digestArrivals } from './inboxArrivals.js';
+import { showDesktopNotification } from './desktopNotifications.js';
+import { useNotificationPreferences } from './notificationPreferences.js';
+
+export interface InboxArrivalNotifierInput {
+ /** The bell's rows, newest first — the same array the popover renders. */
+ notifications: InboxNotification[];
+ /**
+ * The feed's status. Only `ready` is scanned: `loading` and `error` snapshots
+ * carry the LAST value, and priming the session memory off a stale read would
+ * either announce nothing ever again or announce the whole inbox once the
+ * real answer lands.
+ */
+ status: SharedFeedStatus;
+ /** Mark one row read — the bell's own write, so there is no second overlay. */
+ markRead: (id: string) => void | Promise;
+}
+
+/**
+ * Watch the bell's rows and announce what newly arrived.
+ *
+ * Returns nothing: the announcement is the effect. Mounting it twice in one
+ * tree is safe (see the module header).
+ */
+export function useInboxArrivalNotifier({
+ notifications,
+ status,
+ markRead,
+}: InboxArrivalNotifierInput): void {
+ const { t } = useObjectTranslation();
+ const { user } = useAuth();
+ const userId = user?.id ?? null;
+ const navigate = useNavigate();
+ const params = useParams();
+ const { currentAppName } = useNavigationContext();
+ const { apps } = useMetadata();
+ const preferences = useNotificationPreferences();
+
+ const hostAppSegment = resolveHostAppSegment(apps, currentAppName ?? params.appName);
+
+ /**
+ * Everything the announcement needs but must not RE-TRIGGER on. The scan
+ * effect runs on the rows and nothing else: a route change, a metadata load
+ * or a preference flip must not re-enter it. (It would be harmless — a second
+ * scan of a claimed snapshot finds nothing — but "harmless because the store
+ * absorbs it" is a worse guarantee than not running.)
+ */
+ const latest = useRef({ markRead, navigate, hostAppSegment, preferences, t });
+ useEffect(() => {
+ latest.current = { markRead, navigate, hostAppSegment, preferences, t };
+ });
+
+ /**
+ * Open one message: mark it read and go where it points.
+ *
+ * Same reading as the bell's own row click — `resolveNotificationTarget`, and
+ * the full inbox page when a row carries no link (a real state: the producer
+ * leaves `action_url` undefined when an emit has neither a `payload.url` nor
+ * a `source`). Written once here and shared by the toast's action button and
+ * the system notification's click, so the two cannot answer differently.
+ */
+ const openMessage = useCallback((row: InboxNotification) => {
+ const { markRead: mark, navigate: go, hostAppSegment: segment } = latest.current;
+ void mark(row.id);
+ const target = resolveNotificationTarget(row.action_url, segment);
+ if (!target) {
+ go(`/apps/${segment}/sys_inbox_message?view=mine`);
+ return;
+ }
+ if (target.kind === 'external') {
+ window.open(target.url, '_blank', 'noopener,noreferrer');
+ return;
+ }
+ go(target.path);
+ }, []);
+
+ useEffect(() => {
+ if (status !== 'ready' || !userId) return;
+
+ // Claim BEFORE consulting the preferences. A user who switches toasts on
+ // mid-session must not be greeted by every message that arrived while they
+ // were off — those were seen by the session, they were simply not
+ // announced. Claiming first also makes the first answered read prime the
+ // memory even for a user who has everything switched off, so switching
+ // something on later announces the NEXT message and not the inbox.
+ const arrivals = claimInboxArrivals(userId, notifications);
+ const digest = digestArrivals(arrivals);
+ if (!digest) return;
+
+ const { preferences: prefs, t: translate } = latest.current;
+
+ /**
+ * One announcement per cycle, worded by the inbox's own `(topic, title)`
+ * collapse: one group is one thing to say and says it; several groups
+ * summarize. `groups[0].items.length` carries the repeat count for a single
+ * topic that fired several times in one cycle — the same coalescing the
+ * bell shows as `Scheduled project digest x10`.
+ */
+ const single = digest.groups.length === 1;
+ const group = digest.groups[0];
+ const title = single
+ ? group.title || group.type
+ : translate('notifications.arrivalMany', {
+ count: digest.count,
+ defaultValue: '{{count}} new messages',
+ });
+ const body = single
+ ? group.items.length > 1
+ ? translate('notifications.arrivalRepeats', {
+ count: group.items.length,
+ defaultValue: '{{count}} new messages on this topic',
+ })
+ : (digest.target.body ?? undefined) || undefined
+ : digest.target.title;
+
+ // ⭐ Toast and desktop notification are mutually exclusive, decided here and
+ // nowhere else. `visibilityState` rather than `document.hidden` because it
+ // is the card's own criterion and it distinguishes `prerender` too.
+ const visible = typeof document !== 'undefined' && document.visibilityState === 'visible';
+
+ if (visible) {
+ if (!prefs.preferences.toast) return;
+ presentNotificationToast({
+ id: `inbox-arrival-${digest.target.id}`,
+ title,
+ ...(body ? { message: body } : {}),
+ severity: 'info',
+ createdAt: new Date(),
+ icon: 'Bell',
+ actions: [
+ {
+ label: translate('notifications.arrivalOpen', { defaultValue: 'View' }),
+ onClick: () => openMessage(digest.target),
+ },
+ ],
+ });
+ return;
+ }
+
+ // Hidden tab. Silence unless the user opted in AND the browser granted it —
+ // an un-granted browser gets exactly today's behaviour, which is the card's
+ // "completely silent, as it is now" regression criterion.
+ if (!prefs.preferences.desktop || prefs.desktopPermission !== 'granted') return;
+ showDesktopNotification({
+ title,
+ ...(body ? { body } : {}),
+ // Collapse on the topic, so several cycles spent away leave one tray
+ // entry per topic rather than a wall of them.
+ tag: `objectui-inbox-${group.type || group.key}`,
+ onActivate: () => openMessage(digest.target),
+ });
+ }, [notifications, status, userId, openMessage]);
+}
diff --git a/packages/app-shell/src/hooks/useInboxBell.ts b/packages/app-shell/src/hooks/useInboxBell.ts
index 0666f05523..1533ae2c73 100644
--- a/packages/app-shell/src/hooks/useInboxBell.ts
+++ b/packages/app-shell/src/hooks/useInboxBell.ts
@@ -31,7 +31,8 @@
*/
import { useCallback, useMemo, useState } from 'react';
import { bearerAuthHeaders } from '../utils/authToken.js';
-import { useSharedInboxFeed, useSharedPendingApprovalsCount } from './sharedUserFeeds.js';
+import { useSharedInboxFeed, useSharedPendingApprovalsCount, type SharedFeedStatus } from './sharedUserFeeds.js';
+import { useInboxArrivalNotifier } from './useInboxArrivalNotifier.js';
import type { InboxNotification } from '../layout/inboxGrouping.js';
/**
@@ -43,6 +44,12 @@ const EMPTY_READ_IDS: ReadonlySet = new Set();
export interface InboxBell {
/** The shared inbox rows, with this surface's optimistic read flips applied. */
notifications: InboxNotification[];
+ /**
+ * Whether the rows are an ANSWER (`sharedUserFeeds`' four-word dialect).
+ * Surfaced because the arrival announcer may only scan a `ready` snapshot —
+ * a `loading` / `error` one carries the last value, not this cycle's.
+ */
+ status: SharedFeedStatus;
/** Raw unread ROW count (the popover folds it into topics itself). */
unreadCount: number;
/** The badge's second addend — pending approvals waiting on this user. */
@@ -61,7 +68,7 @@ export function useInboxBell(): InboxBell {
* does not read the re-modeled `sys_notification` L2 event (which carries no
* recipient/read columns).
*/
- const { value: inboxMessages } = useSharedInboxFeed();
+ const { value: inboxMessages, status } = useSharedInboxFeed();
/**
* Optimistic read-state, layered over the shared rows.
@@ -153,8 +160,26 @@ export function useInboxBell(): InboxBell {
try { await postMarkRead('read', notifIds); } catch { /* best-effort */ }
}, [notifications, markLocallyRead, postMarkRead]);
+ /**
+ * objectui#7011 — announce what newly ARRIVED (toast while the tab is
+ * visible, a system notification while it is hidden).
+ *
+ * Mounted here rather than in `AppHeader` for the reason this hook exists at
+ * all: it is the ONE wiring of the shared feed onto a bell, so the header
+ * bell and the `global:notifications` page block announce by the same rules
+ * and through the same `markRead`. Mounting both at once is safe — the seen
+ * set that decides what is new is module-scoped, so the first scan of a
+ * snapshot takes its arrivals and the second finds none.
+ *
+ * It is given `notifications` (the overlay applied) rather than the raw feed
+ * rows so a row the user just marked read in this tab cannot be announced by
+ * a poll that has not yet seen the receipt.
+ */
+ useInboxArrivalNotifier({ notifications, status, markRead });
+
return {
notifications,
+ status,
unreadCount,
pendingApprovalsCount,
markAllRead,
diff --git a/packages/app-shell/src/layout/AppHeader.tsx b/packages/app-shell/src/layout/AppHeader.tsx
index d9c1c64bed..76eccb3d28 100644
--- a/packages/app-shell/src/layout/AppHeader.tsx
+++ b/packages/app-shell/src/layout/AppHeader.tsx
@@ -58,6 +58,7 @@ import { ModeToggle } from './ModeToggle.js';
import { WorkspaceSwitcher } from './WorkspaceSwitcher.js';
import { CurrentOrganizationIndicator } from './CurrentOrganizationIndicator.js';
import { LocaleSwitcher } from './LocaleSwitcher.js';
+import { NotificationPreferencesMenu } from './NotificationPreferencesMenu.js';
import { ConnectionStatus } from './ConnectionStatus.js';
import type { ActivityItem } from './ActivityFeed.js';
import { InboxPopover } from './InboxPopover.js';
@@ -893,6 +894,15 @@ export function AppHeader({
+ {/*
+ * objectui#7011 — the two inbox announcement switches. They belong
+ * beside theme and language for the same reason those moved here:
+ * browser-local preferences a user sets once. The desktop switch is
+ * also the ONLY path to `Notification.requestPermission()` in this
+ * console, and keeping it behind a deliberate gesture is what stops
+ * a load-time prompt from spending that channel permanently.
+ */}
+
{isAuthEnabled && (
<>
diff --git a/packages/app-shell/src/layout/NotificationPreferencesMenu.tsx b/packages/app-shell/src/layout/NotificationPreferencesMenu.tsx
new file mode 100644
index 0000000000..e3951c3ca0
--- /dev/null
+++ b/packages/app-shell/src/layout/NotificationPreferencesMenu.tsx
@@ -0,0 +1,102 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * NotificationPreferencesMenu — the two announcement switches, in the account
+ * menu's Preferences section (objectui#7011).
+ *
+ * It sits beside Theme and Language for the reason those two are there: a
+ * rarely-touched, browser-local preference that would not earn a top-bar
+ * button. Rendered as plain rows rather than `DropdownMenuItem`s, matching
+ * `ModeToggle` / `LocaleSwitcher` above it — a menu ITEM closes the menu when
+ * activated, which would shut the panel the moment a switch is flipped and hide
+ * the permission outcome the user needs to see.
+ *
+ * ## The desktop switch is the whole reason this file is careful
+ *
+ * Flipping it on is the ONLY thing in this console that calls
+ * `Notification.requestPermission()`. That is not an implementation detail to
+ * be tidied later: a browser answers the prompt once and `denied` is permanent
+ * for the origin, so a page that asks on load spends a channel the user never
+ * agreed to open, for every user who reflexively blocks. Asking from this one
+ * gesture means the prompt always arrives with a reason the user just supplied.
+ *
+ * Three states, three answers:
+ * - no Notification API in this browser → the row is disabled and says so;
+ * - `denied` → disabled, and the hint points at browser settings, because
+ * nothing this app can do will change it;
+ * - otherwise → live, and the switch's position follows the VERDICT rather
+ * than the click (a user who blocks the prompt gets the switch back off,
+ * which is what will actually happen at delivery time).
+ */
+
+import { Switch } from '@object-ui/components';
+import { useObjectTranslation } from '@object-ui/i18n';
+import { useNotificationPreferences } from '../hooks/notificationPreferences.js';
+
+export function NotificationPreferencesMenu() {
+ const { t } = useObjectTranslation();
+ const {
+ preferences,
+ desktopPermission,
+ desktopSupported,
+ setToastEnabled,
+ enableDesktop,
+ disableDesktop,
+ } = useNotificationPreferences();
+
+ const desktopBlocked = desktopPermission === 'denied';
+ const desktopDisabled = !desktopSupported || desktopBlocked;
+
+ const hint = !desktopSupported
+ ? t('notifications.desktopUnsupported', {
+ defaultValue: 'This browser does not support desktop notifications.',
+ })
+ : desktopBlocked
+ ? t('notifications.desktopBlocked', {
+ defaultValue: 'Blocked. Allow notifications for this site in your browser settings.',
+ })
+ : null;
+
+ return (
+ <>
+
+ )}
+ >
+ );
+}
diff --git a/packages/app-shell/src/layout/__tests__/NotificationPreferencesMenu.test.tsx b/packages/app-shell/src/layout/__tests__/NotificationPreferencesMenu.test.tsx
new file mode 100644
index 0000000000..952ea4083e
--- /dev/null
+++ b/packages/app-shell/src/layout/__tests__/NotificationPreferencesMenu.test.tsx
@@ -0,0 +1,170 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * objectui#7011 — the permission prompt has exactly ONE trigger: this toggle.
+ *
+ * ## What this file is really guarding
+ *
+ * `Notification.requestPermission()` is answered once per origin, and `denied`
+ * is permanent as far as the page is concerned — no later release recovers it.
+ * So "asked from a deliberate user gesture" is not a UX preference, it is the
+ * difference between a channel this product has and one it has spent. The
+ * companion pin (`hooks/__tests__/useInboxArrivalNotifier.test.tsx`) asserts the
+ * PRESENTER never asks; this one asserts the toggle does — because a pin that
+ * only ever counts zero passes just as well on an implementation that removed
+ * the call entirely, and that implementation ships a switch that does nothing.
+ *
+ * ## Harness
+ *
+ * `Notification` is absent in happy-dom, so it is stubbed per case with a
+ * concrete, distinguishable verdict; the "no API at all" case then removes it
+ * again, which is a real browser state (and the only one where the row must be
+ * disabled for a reason other than a denial).
+ */
+import '@testing-library/jest-dom/vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import React from 'react';
+
+vi.mock('@object-ui/auth', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useAuth: () => ({ user: { id: 'u_alice' } }),
+}));
+
+import { NotificationPreferencesMenu } from '../NotificationPreferencesMenu';
+import {
+ NOTIFICATION_PREFERENCES_KEY,
+ readNotificationPreferences,
+ writeNotificationPreferences,
+ __resetNotificationPreferences,
+} from '../../hooks/notificationPreferences';
+
+const requestPermission = vi.fn();
+
+function stubNotification(permission: 'granted' | 'denied' | 'default') {
+ class FakeNotification {
+ static permission = permission;
+ static requestPermission = requestPermission;
+ }
+ vi.stubGlobal('Notification', FakeNotification);
+ return FakeNotification;
+}
+
+beforeEach(() => {
+ requestPermission.mockReset();
+ window.localStorage.clear();
+ // Module-scoped store: reset it, or a case reads the previous one's switches.
+ __resetNotificationPreferences();
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ window.localStorage.clear();
+});
+
+describe('the shipped defaults', () => {
+ it('offers in-app alerts ON and desktop notifications OFF', () => {
+ stubNotification('default');
+ render();
+
+ expect(screen.getByTestId('notification-toast-toggle')).toHaveAttribute('data-state', 'checked');
+ expect(screen.getByTestId('notification-desktop-toggle')).toHaveAttribute('data-state', 'unchecked');
+ });
+
+ it('⭐ rendering the switches does NOT ask for permission', () => {
+ stubNotification('default');
+ render();
+
+ // Reading the verdict to grey the row is not requesting it.
+ expect(requestPermission).not.toHaveBeenCalled();
+ });
+});
+
+describe('the in-app alerts switch', () => {
+ it('persists the user turning it off, per user id', () => {
+ stubNotification('default');
+ render();
+
+ fireEvent.click(screen.getByTestId('notification-toast-toggle'));
+
+ expect(readNotificationPreferences('u_alice').toast).toBe(false);
+ // Scoped: another account on this browser is unaffected.
+ expect(readNotificationPreferences('u_bob').toast).toBe(true);
+ expect(window.localStorage.getItem(`${NOTIFICATION_PREFERENCES_KEY}:u:u_alice`)).toContain('"toast":false');
+ });
+});
+
+describe('⭐ the desktop switch is the one and only permission prompt', () => {
+ it('asks exactly once, when the user turns it on', async () => {
+ stubNotification('default');
+ requestPermission.mockResolvedValue('granted');
+ render();
+
+ fireEvent.click(screen.getByTestId('notification-desktop-toggle'));
+
+ await waitFor(() => expect(requestPermission).toHaveBeenCalledTimes(1));
+ });
+
+ it('records the preference from the VERDICT when the user grants it', async () => {
+ const Fake = stubNotification('default');
+ requestPermission.mockImplementation(async () => {
+ // A real browser updates `Notification.permission` alongside the verdict.
+ (Fake as { permission: string }).permission = 'granted';
+ return 'granted';
+ });
+ render();
+
+ fireEvent.click(screen.getByTestId('notification-desktop-toggle'));
+
+ await waitFor(() => expect(readNotificationPreferences('u_alice').desktop).toBe(true));
+ await waitFor(() =>
+ expect(screen.getByTestId('notification-desktop-toggle')).toHaveAttribute('data-state', 'checked'),
+ );
+ });
+
+ it('leaves the switch OFF when the user blocks the prompt — the switch tells the truth', async () => {
+ const Fake = stubNotification('default');
+ requestPermission.mockImplementation(async () => {
+ (Fake as { permission: string }).permission = 'denied';
+ return 'denied';
+ });
+ render();
+
+ fireEvent.click(screen.getByTestId('notification-desktop-toggle'));
+
+ await waitFor(() => expect(readNotificationPreferences('u_alice').desktop).toBe(false));
+ expect(screen.getByTestId('notification-desktop-toggle')).toHaveAttribute('data-state', 'unchecked');
+ });
+
+ it('greys the row and points at browser settings once permission is denied', () => {
+ stubNotification('denied');
+ render();
+
+ const toggle = screen.getByTestId('notification-desktop-toggle');
+ expect(toggle).toBeDisabled();
+ expect(screen.getByTestId('notification-desktop-hint')).toHaveTextContent(/browser settings/i);
+ // ...and it does not re-ask, because nothing this app does can change it.
+ fireEvent.click(toggle);
+ expect(requestPermission).not.toHaveBeenCalled();
+ });
+
+ it('greys the row and says so where the browser has no Notification API', () => {
+ vi.stubGlobal('Notification', undefined);
+ render();
+
+ expect(screen.getByTestId('notification-desktop-toggle')).toBeDisabled();
+ expect(screen.getByTestId('notification-desktop-hint')).toHaveTextContent(/does not support/i);
+ });
+
+ it('a switch shown ON is one the browser will actually honour', () => {
+ // A stored `desktop: true` whose permission was revoked in browser chrome
+ // must not render as ON: the row would promise deliveries that cannot come.
+ writeNotificationPreferences('u_alice', { toast: true, desktop: true });
+ stubNotification('denied');
+
+ render();
+
+ expect(screen.getByTestId('notification-desktop-toggle')).toHaveAttribute('data-state', 'unchecked');
+ });
+});
diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts
index 537ef4ce8d..8fa5e962cb 100644
--- a/packages/i18n/src/locales/ar.ts
+++ b/packages/i18n/src/locales/ar.ts
@@ -2768,6 +2768,13 @@ const ar = {
badgeTotal: "{{total}} إجمالاً",
badgeNotifications: "{{unread}} إشعارات",
badgeApprovals: "{{approvals}} موافقات معلقة",
+ arrivalMany: "{{count}} رسائل جديدة",
+ arrivalRepeats: "{{count}} رسائل جديدة في هذا الموضوع",
+ arrivalOpen: "عرض",
+ toastEnabled: "تنبيهات داخل التطبيق",
+ desktopEnabled: "إشعارات سطح المكتب",
+ desktopBlocked: "محظور. اسمح بالإشعارات لهذا الموقع من إعدادات المتصفح.",
+ desktopUnsupported: "هذا المتصفح لا يدعم إشعارات سطح المكتب.",
emptyUnread: "كل شيء مقروء",
filterUnread: "غير مقروء",
filterAll: "الكل",
diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts
index 1db3a1dcfb..56f3f1884c 100644
--- a/packages/i18n/src/locales/de.ts
+++ b/packages/i18n/src/locales/de.ts
@@ -2761,6 +2761,13 @@ const de = {
badgeTotal: "{{total}} insgesamt",
badgeNotifications: "{{unread}} Benachrichtigungen",
badgeApprovals: "{{approvals}} ausstehende Genehmigungen",
+ arrivalMany: "{{count}} neue Nachrichten",
+ arrivalRepeats: "{{count}} neue Nachrichten zu diesem Thema",
+ arrivalOpen: "Ansehen",
+ toastEnabled: "Hinweise in der App",
+ desktopEnabled: "Desktop-Benachrichtigungen",
+ desktopBlocked: "Blockiert. Erlauben Sie Benachrichtigungen für diese Website in den Browsereinstellungen.",
+ desktopUnsupported: "Dieser Browser unterstützt keine Desktop-Benachrichtigungen.",
emptyUnread: "Alles gelesen",
filterUnread: "Ungelesen",
filterAll: "Alle",
diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts
index e70f092bf7..86a6aee9bb 100644
--- a/packages/i18n/src/locales/en.ts
+++ b/packages/i18n/src/locales/en.ts
@@ -3150,6 +3150,15 @@ const en = {
badgeTotal: '{{total}} total',
badgeNotifications: '{{unread}} notifications',
badgeApprovals: '{{approvals}} pending approvals',
+ // objectui#7011 — the arrival announcement (toast / desktop notification)
+ // and the two switches that govern it.
+ arrivalMany: '{{count}} new messages',
+ arrivalRepeats: '{{count}} new messages on this topic',
+ arrivalOpen: 'View',
+ toastEnabled: 'In-app alerts',
+ desktopEnabled: 'Desktop notifications',
+ desktopBlocked: 'Blocked. Allow notifications for this site in your browser settings.',
+ desktopUnsupported: 'This browser does not support desktop notifications.',
},
publicForm: {
submit: 'Submit',
diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts
index ab0b85f78e..29c8f17019 100644
--- a/packages/i18n/src/locales/es.ts
+++ b/packages/i18n/src/locales/es.ts
@@ -2765,6 +2765,13 @@ const es = {
badgeTotal: "{{total}} en total",
badgeNotifications: "{{unread}} notificaciones",
badgeApprovals: "{{approvals}} aprobaciones pendientes",
+ arrivalMany: "{{count}} mensajes nuevos",
+ arrivalRepeats: "{{count}} mensajes nuevos sobre este tema",
+ arrivalOpen: "Ver",
+ toastEnabled: "Avisos en la aplicación",
+ desktopEnabled: "Notificaciones de escritorio",
+ desktopBlocked: "Bloqueado. Permite las notificaciones de este sitio en la configuración del navegador.",
+ desktopUnsupported: "Este navegador no admite notificaciones de escritorio.",
emptyUnread: "Todo al día",
filterUnread: "No leídos",
filterAll: "Todos",
diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts
index d2fac6e878..15130e525f 100644
--- a/packages/i18n/src/locales/fr.ts
+++ b/packages/i18n/src/locales/fr.ts
@@ -2763,6 +2763,13 @@ const fr = {
badgeTotal: "{{total}} au total",
badgeNotifications: "{{unread}} notifications",
badgeApprovals: "{{approvals}} approbations en attente",
+ arrivalMany: "{{count}} nouveaux messages",
+ arrivalRepeats: "{{count}} nouveaux messages sur ce sujet",
+ arrivalOpen: "Afficher",
+ toastEnabled: "Alertes dans l'application",
+ desktopEnabled: "Notifications du bureau",
+ desktopBlocked: "Bloqué. Autorisez les notifications pour ce site dans les paramètres du navigateur.",
+ desktopUnsupported: "Ce navigateur ne prend pas en charge les notifications du bureau.",
emptyUnread: "Tout est lu",
filterUnread: "Non lus",
filterAll: "Tous",
diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts
index 032b5cdfcc..382d4dfb27 100644
--- a/packages/i18n/src/locales/ja.ts
+++ b/packages/i18n/src/locales/ja.ts
@@ -2763,6 +2763,13 @@ const ja = {
badgeTotal: "合計 {{total}} 件",
badgeNotifications: "通知 {{unread}} 件",
badgeApprovals: "承認待ち {{approvals}} 件",
+ arrivalMany: "新着メッセージ {{count}} 件",
+ arrivalRepeats: "このトピックの新着メッセージ {{count}} 件",
+ arrivalOpen: "表示",
+ toastEnabled: "アプリ内通知",
+ desktopEnabled: "デスクトップ通知",
+ desktopBlocked: "ブロックされています。ブラウザーの設定でこのサイトの通知を許可してください。",
+ desktopUnsupported: "このブラウザーはデスクトップ通知に対応していません。",
emptyUnread: "既読にしました",
filterUnread: "未読",
filterAll: "すべて",
diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts
index 3dfffaa3d8..a43424f4fb 100644
--- a/packages/i18n/src/locales/ko.ts
+++ b/packages/i18n/src/locales/ko.ts
@@ -2760,6 +2760,13 @@ const ko = {
badgeTotal: "총 {{total}}건",
badgeNotifications: "알림 {{unread}}건",
badgeApprovals: "승인 대기 {{approvals}}건",
+ arrivalMany: "새 메시지 {{count}}건",
+ arrivalRepeats: "이 주제의 새 메시지 {{count}}건",
+ arrivalOpen: "보기",
+ toastEnabled: "앱 내 알림",
+ desktopEnabled: "데스크톱 알림",
+ desktopBlocked: "차단됨. 브라우저 설정에서 이 사이트의 알림을 허용하세요.",
+ desktopUnsupported: "이 브라우저는 데스크톱 알림을 지원하지 않습니다.",
emptyUnread: "모두 읽음",
filterUnread: "읽지 않음",
filterAll: "전체",
diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts
index 5769168696..0dd1c074ba 100644
--- a/packages/i18n/src/locales/pt.ts
+++ b/packages/i18n/src/locales/pt.ts
@@ -2760,6 +2760,13 @@ const pt = {
badgeTotal: "{{total}} no total",
badgeNotifications: "{{unread}} notificações",
badgeApprovals: "{{approvals}} aprovações pendentes",
+ arrivalMany: "{{count}} novas mensagens",
+ arrivalRepeats: "{{count}} novas mensagens sobre este tópico",
+ arrivalOpen: "Ver",
+ toastEnabled: "Avisos no aplicativo",
+ desktopEnabled: "Notificações da área de trabalho",
+ desktopBlocked: "Bloqueado. Permita notificações deste site nas configurações do navegador.",
+ desktopUnsupported: "Este navegador não oferece suporte a notificações da área de trabalho.",
emptyUnread: "Tudo lido",
filterUnread: "Não lidos",
filterAll: "Todos",
diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts
index 566e884e09..cdad3561fd 100644
--- a/packages/i18n/src/locales/ru.ts
+++ b/packages/i18n/src/locales/ru.ts
@@ -2774,6 +2774,13 @@ const ru = {
badgeTotal: "Всего: {{total}}",
badgeNotifications: "{{unread}} уведомлений",
badgeApprovals: "{{approvals}} ожидающих утверждений",
+ arrivalMany: "Новых сообщений: {{count}}",
+ arrivalRepeats: "Новых сообщений по этой теме: {{count}}",
+ arrivalOpen: "Открыть",
+ toastEnabled: "Оповещения в приложении",
+ desktopEnabled: "Уведомления на рабочем столе",
+ desktopBlocked: "Заблокировано. Разрешите уведомления для этого сайта в настройках браузера.",
+ desktopUnsupported: "Этот браузер не поддерживает уведомления на рабочем столе.",
emptyUnread: "Всё прочитано",
filterUnread: "Непрочитанные",
filterAll: "Все",
diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts
index 425d0e6f6d..1cc48caa93 100644
--- a/packages/i18n/src/locales/zh.ts
+++ b/packages/i18n/src/locales/zh.ts
@@ -2901,6 +2901,13 @@ const zh = {
badgeTotal: '共 {{total}} 项',
badgeNotifications: '{{unread}} 条通知',
badgeApprovals: '{{approvals}} 条待审批',
+ arrivalMany: '{{count}} 条新消息',
+ arrivalRepeats: '该主题有 {{count}} 条新消息',
+ arrivalOpen: '查看',
+ toastEnabled: '站内提醒',
+ desktopEnabled: '桌面通知',
+ desktopBlocked: '已被拦截。请在浏览器设置中允许本站发送通知。',
+ desktopUnsupported: '当前浏览器不支持桌面通知。',
},
publicForm: {
submit: '提交',
diff --git a/scripts/inbox-arrival-browser-check.mjs b/scripts/inbox-arrival-browser-check.mjs
new file mode 100644
index 0000000000..3f877f0d58
--- /dev/null
+++ b/scripts/inbox-arrival-browser-check.mjs
@@ -0,0 +1,292 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * Real-browser check for objectui#7011 — drives `inbox-arrival-preview.html` in
+ * a real Chromium and reports what the presentation layer actually did.
+ *
+ * ## Why this is not a vitest file
+ *
+ * The two APIs this feature turns on are the two happy-dom does not implement:
+ * `Notification` is absent, and `document.visibilityState` is a prototype getter
+ * a unit test can only fake. Those unit pins are worth having — they are fast
+ * and they pin the negative cases — but their failure mode is the bad one: a
+ * suite reporting "no desktop notification was raised" for a run in which
+ * raising one was never possible. This script measures the same claims against
+ * the real API, with a real browser permission grant.
+ *
+ * It already earned its keep: it found that the settings menu and the presenter
+ * held SEPARATE copies of the preferences, so switching desktop notifications on
+ * did nothing until the page was reloaded. Every unit pin was green, because a
+ * hook mounted alone cannot disagree with itself.
+ *
+ * ## Running it
+ *
+ * pnpm --filter @object-ui/console exec vite --port 5310 --strictPort &
+ * node scripts/inbox-arrival-browser-check.mjs --port 5310
+ *
+ * Chromium comes from the image's stable alias. ⛔ Never the versioned
+ * `chromium-NNNN/...` spelling — that path dies on an image bump and reads as
+ * "no browser here" when the browser is present.
+ *
+ * ## Two contexts, because permission is a per-origin fact
+ *
+ * A Playwright context that was `grantPermissions(['notifications'])` reports
+ * `granted` from the start, and the production code deliberately does NOT prompt
+ * over a settled verdict — so the prompt counter can only be LIT in a context
+ * that was never granted. Running both is what makes the "never requested"
+ * reading a measurement rather than an assumption: the same counter reaches 1 in
+ * the second context, from the toggle, and only from the toggle.
+ */
+import { chromium } from '@playwright/test';
+
+const portArg = process.argv.indexOf('--port');
+const PORT = portArg > -1 ? process.argv[portArg + 1] : '5310';
+const ORIGIN = `http://localhost:${PORT}`;
+const PAGE_URL = `${ORIGIN}/inbox-arrival-preview.html`;
+
+const results = [];
+function check(name, actual, expected) {
+ const ok = JSON.stringify(actual) === JSON.stringify(expected);
+ results.push({ name, ok, actual, expected });
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name}`);
+ if (!ok) console.log(` expected ${JSON.stringify(expected)}\n actual ${JSON.stringify(actual)}`);
+}
+
+/**
+ * Installed before any page script. Three instruments:
+ * - a controllable `document.visibilityState` (the real one is read-only);
+ * - a recorder AROUND the real `Notification`, so a raised notification is
+ * observable without stopping it from being a real one;
+ * - a counter on `requestPermission` — the whole non-regression axis.
+ */
+const INIT = `
+ let __visibility = 'visible';
+ Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => __visibility });
+ Object.defineProperty(document, 'hidden', { configurable: true, get: () => __visibility === 'hidden' });
+ window.__setVisibility = (value) => {
+ __visibility = value;
+ document.dispatchEvent(new Event('visibilitychange'));
+ };
+
+ window.__permissionRequests = 0;
+ window.__desktopNotifications = [];
+ const Real = window.Notification;
+ function Recorder(title, options) {
+ let instance;
+ try { instance = new Real(title, options); }
+ catch (err) { instance = { onclick: null, close() {}, __synthetic: String(err) }; }
+ window.__desktopNotifications.push({ title, options, instance });
+ return instance;
+ }
+ Recorder.prototype = Real.prototype;
+ Object.defineProperty(Recorder, 'permission', { get: () => Real.permission });
+ Recorder.requestPermission = function (...args) {
+ window.__permissionRequests += 1;
+ return Real.requestPermission.apply(Real, args);
+ };
+ window.Notification = Recorder;
+`;
+
+const browser = await chromium.launch({ executablePath: '/opt/pw-browsers/chromium' });
+const pageErrors = [];
+
+async function openPage(context) {
+ await context.addInitScript(INIT);
+ const page = await context.newPage();
+ page.on('pageerror', (err) => pageErrors.push(String(err)));
+ page.on('console', (msg) => {
+ // The favicon 404 a standalone preview page draws is not this feature's
+ // business; anything else is.
+ if (msg.type() === 'error' && !msg.text().includes('Failed to load resource')) pageErrors.push(msg.text());
+ });
+ await page.goto(PAGE_URL, { waitUntil: 'networkidle' });
+ await page.waitForSelector('[data-testid="fx-first-read"]');
+ return page;
+}
+
+const helpers = (page) => ({
+ toasts: () => page.locator('[data-sonner-toast]'),
+ raised: () => page.evaluate(() => window.__desktopNotifications.map((n) => ({ title: n.title, tag: n.options?.tag }))),
+ requests: () => page.evaluate(() => window.__permissionRequests),
+ log: () => page.evaluate(() => (document.querySelector('[data-testid="fx-log"]')?.textContent ?? '').trim()),
+ /** Back to a clean session: memory forgotten, toasts dismissed, rows dropped. */
+ reset: async () => { await page.click('[data-testid="fx-reset"]'); await page.waitForTimeout(400); },
+ firstRead: async () => { await page.click('[data-testid="fx-first-read"]'); await page.waitForTimeout(400); },
+ arrive: async () => { await page.click('[data-testid="fx-one-arrival"]'); await page.waitForTimeout(700); },
+ arriveThree: async () => { await page.click('[data-testid="fx-three-arrivals"]'); await page.waitForTimeout(700); },
+ hide: () => page.evaluate(() => window.__setVisibility('hidden')),
+ show: () => page.evaluate(() => window.__setVisibility('visible')),
+});
+
+// ═══ Context A — the browser has GRANTED notifications ══════════════════════
+{
+ const context = await browser.newContext();
+ await context.grantPermissions(['notifications'], { origin: ORIGIN });
+ const page = await openPage(context);
+ const h = helpers(page);
+
+ check(
+ 'control: the real Notification API is present and granted in this context',
+ await page.evaluate(() => ({ type: typeof window.Notification, permission: window.Notification.permission })),
+ { type: 'function', permission: 'granted' },
+ );
+
+ // ── Acceptance 2 / constraint 1 ───────────────────────────────────────────
+ await h.firstRead();
+ check('acceptance 2: the first read of 3 historical unread raises NO toast', await h.toasts().count(), 0);
+ check('acceptance 2: ...and no desktop notification either', await h.raised(), []);
+
+ // ── Acceptance 1 ──────────────────────────────────────────────────────────
+ await h.arrive();
+ check('acceptance 1: one new message raises exactly one toast', await h.toasts().count(), 1);
+ check(
+ 'acceptance 1: the toast names the message',
+ await h.toasts().first().innerText().then((text) => text.includes('Assigned to you: m4')),
+ true,
+ );
+ await page.getByRole('button', { name: 'View' }).click();
+ await page.waitForTimeout(400);
+ // The tail, not the whole log: StrictMode mounts the tree twice in dev, so
+ // the initial `/home` location is logged twice. That is a property of the
+ // FIXTURE's own logger, not of the feature, and pinning it would make this
+ // check fail the day the fixture stops using StrictMode.
+ check(
+ 'acceptance 1: clicking it marks the row read and deep-links to it',
+ await h.log().then((text) => text.split('\n').slice(-2)),
+ ['markRead:m4', 'navigate:/apps/setup/showcase_task/m4'],
+ );
+
+ // ── Acceptance 5 ──────────────────────────────────────────────────────────
+ await h.reset();
+ await h.firstRead();
+ await h.arriveThree();
+ check('acceptance 5: three messages in one cycle raise ONE toast, not three', await h.toasts().count(), 1);
+ check(
+ 'acceptance 5: ...and it summarizes rather than picking a winner',
+ await h.toasts().first().innerText().then((text) => /3/.test(text)),
+ true,
+ );
+
+ // ── Acceptance 4 — the user never opted in, so a hidden tab is silent ─────
+ await h.reset();
+ await h.hide();
+ await h.firstRead();
+ await h.arrive();
+ check(
+ 'acceptance 4: a hidden tab whose user never opted in stays completely silent',
+ { toasts: await h.toasts().count(), desktop: await h.raised() },
+ { toasts: 0, desktop: [] },
+ );
+
+ // ⭐ Nothing so far touched the prompt. Context B lights this same counter.
+ check('⭐ permission was NEVER requested on load, on refresh, or on arrival', await h.requests(), 0);
+
+ // ── Acceptance 3 / constraint 5 — opted in + granted + hidden ⇒ desktop ───
+ await h.show();
+ await page.click('[data-testid="notification-desktop-toggle"]');
+ await page.waitForTimeout(400);
+ check(
+ 'the switch reaches the presenter in the SAME tab (no reload)',
+ await page.evaluate(() => window.__inboxArrivalFixture.storedPreferences()),
+ '{"toast":true,"desktop":true}',
+ );
+ check('...and an already-granted browser is not prompted again', await h.requests(), 0);
+
+ await h.reset();
+ await h.hide();
+ await h.firstRead();
+ await h.arrive();
+ check(
+ 'acceptance 3: a hidden tab gets the DESKTOP notification',
+ await h.raised().then((all) => all.map((n) => n.title)),
+ ['Assigned to you: m4'],
+ );
+ check('constraint 5: ...and no toast was raised behind it', await h.toasts().count(), 0);
+
+ // ── Constraint 5, the other direction ─────────────────────────────────────
+ await h.reset();
+ await h.show();
+ const desktopBefore = (await h.raised()).length;
+ await h.firstRead();
+ await h.arrive();
+ check(
+ 'constraint 5: a VISIBLE tab gets the toast and no new desktop notification',
+ { toasts: await h.toasts().count(), newDesktop: (await h.raised()).length - desktopBefore },
+ { toasts: 1, newDesktop: 0 },
+ );
+
+ await context.close();
+}
+
+// ═══ Context B — never granted, so the verdict is unsettled ═════════════════
+{
+ const context = await browser.newContext();
+ const page = await openPage(context);
+ const h = helpers(page);
+
+ check(
+ 'control: an un-granted context reports an UNSETTLED verdict',
+ await page.evaluate(() => window.Notification.permission),
+ 'default',
+ );
+
+ // Everything the presenter does, with the prompt still unasked.
+ await h.firstRead();
+ await h.arrive();
+ await h.hide();
+ await h.arrive();
+ check('⭐ still never requested — not on mount, refresh, arrival, or hide', await h.requests(), 0);
+
+ // ⭐ The lit control for that zero: the toggle, and only the toggle, prompts.
+ await h.show();
+ await page.click('[data-testid="notification-desktop-toggle"]');
+ await page.waitForTimeout(600);
+ check('⭐ the settings toggle DOES prompt (lights the counter above)', await h.requests(), 1);
+
+ // Headless Chromium answers an un-granted prompt with `denied` — which is the
+ // permanent verdict this whole design exists to avoid spending by accident.
+ /**
+ * Headless Chromium answers an un-granted prompt with `denied`, but whether it
+ * then PERSISTS that verdict on the origin is its own business — it was
+ * observed doing both. So the invariant asserted here is the one that is the
+ * product's: the switch never claims a channel it does not have, nothing is
+ * stored as enabled, and the "go to browser settings" hint appears exactly
+ * when the browser actually reports `denied` — never on a verdict that is
+ * still open, which would tell the user to fix something that is not broken.
+ */
+ // Headless Chromium auto-answers an un-granted prompt, but not always
+ // promptly and not always by PERSISTING the verdict — both were observed. So
+ // wait for the settle rather than sampling once, and then assert the
+ // invariants that are the PRODUCT's regardless of which way it went.
+ let settled = null;
+ for (let i = 0; i < 40 && settled === null; i += 1) {
+ settled = await page.evaluate(() => window.__inboxArrivalFixture.storedPreferences());
+ if (settled === null) await page.waitForTimeout(250);
+ }
+ const verdict = await page.evaluate(() => window.Notification.permission);
+ const hint = await page.locator('[data-testid="notification-desktop-hint"]').count();
+ console.log(` (headless verdict after the prompt: ${verdict}; stored: ${settled})`);
+ check(
+ 'a blocked prompt leaves the switch off and stores nothing enabled',
+ {
+ state: await page.getAttribute('[data-testid="notification-desktop-toggle"]', 'data-state'),
+ stored: settled,
+ // The "go to browser settings" hint appears exactly when the browser
+ // really says `denied` — never over a verdict that is still open, which
+ // would tell the user to fix something that is not broken.
+ hintMatchesVerdict: (verdict === 'denied') === (hint > 0),
+ },
+ { state: 'unchecked', stored: '{"toast":true,"desktop":false}', hintMatchesVerdict: true },
+ );
+
+ await context.close();
+}
+
+check('no page errors', pageErrors, []);
+
+await browser.close();
+
+const failed = results.filter((r) => !r.ok);
+console.log(`\n${results.length - failed.length}/${results.length} checks passed`);
+process.exit(failed.length === 0 ? 0 : 1);