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 ( + <> +
+ + {t('notifications.toastEnabled', { defaultValue: 'In-app alerts' })} + + +
+
+ + {t('notifications.desktopEnabled', { defaultValue: 'Desktop notifications' })} + + { + if (next) void enableDesktop(); + else disableDesktop(); + }} + data-testid="notification-desktop-toggle" + aria-label={t('notifications.desktopEnabled', { defaultValue: 'Desktop notifications' })} + /> +
+ {hint && ( +

+ {hint} +

+ )} + + ); +} 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);