From adde6c3a29d45240cf80b7e5b41fbee0abdd58e5 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Thu, 3 Sep 2026 15:39:22 -0700 Subject: [PATCH] feat: notification GUI rides the events surface (PUT-1685) (#3689) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widen the notif: match filter and fetch scope for a session's own generic developer/app-user subscribe: today it pins ref to the session's own uuid, so a row naming an app (handler-suspension notices, app-bound worker deploys) never matches live and never replays on reconnect, even though the audience predicate already grants the holder every such row it owns. The predicate is the authority and already reruns per row/page after the match, so widening the filter to it (account is unaffected — it never names an app) adds no exposure. --- .../notification/NotificationDriver.ts | 6 +- src/backend/services/events/EventsService.ts | 34 +- src/backend/services/events/anchors.test.ts | 63 ++- src/backend/services/events/anchors.ts | 13 +- .../services/events/notifFetch.test.ts | 18 + src/backend/services/events/notifFetch.ts | 14 +- .../events/notifications.integration.test.ts | 93 +++++ .../homepage/PuterHomepageService.test.ts | 27 ++ .../services/homepage/PuterHomepageService.ts | 5 + .../notification/notificationSocket.ts | 5 + .../stores/notification/NotificationStore.js | 13 +- .../notification/NotificationStore.test.js | 51 +++ .../UI/Dashboard/UIDashboardNotifications.js | 39 +- src/gui/src/UI/UIDesktop.js | 63 ++- src/gui/src/helpers/notificationApi.js | 30 ++ src/gui/src/helpers/notificationFeed.js | 315 +++++++++++++++ src/gui/src/helpers/notificationFeed.test.js | 361 ++++++++++++++++++ 17 files changed, 1095 insertions(+), 55 deletions(-) create mode 100644 src/gui/src/helpers/notificationFeed.js create mode 100644 src/gui/src/helpers/notificationFeed.test.js diff --git a/src/backend/drivers/notification/NotificationDriver.ts b/src/backend/drivers/notification/NotificationDriver.ts index e140ab9ee..5f9d6de4a 100644 --- a/src/backend/drivers/notification/NotificationDriver.ts +++ b/src/backend/drivers/notification/NotificationDriver.ts @@ -40,7 +40,8 @@ const MAX_SELECT_LIMIT = 200; /** The mailbox slice a call named, in the terms the query takes it. */ interface MailboxScope { audiences: readonly string[]; - appUid: string | null; + /** `null` is the rows naming no app; `undefined` is any app. */ + appUid: string | null | undefined; } /** Whether a row belongs to the slice, as the same SQL scope selected it. */ @@ -49,7 +50,8 @@ const inScope = ( scope: MailboxScope, ): boolean => { const { audience, appUid } = notificationRowScope(row); - return scope.audiences.includes(audience) && appUid === scope.appUid; + if (!scope.audiences.includes(audience)) return false; + return scope.appUid === undefined || appUid === scope.appUid; }; /** diff --git a/src/backend/services/events/EventsService.ts b/src/backend/services/events/EventsService.ts index 6f3978e4f..fcd31eb53 100644 --- a/src/backend/services/events/EventsService.ts +++ b/src/backend/services/events/EventsService.ts @@ -136,11 +136,7 @@ import { relativeTo, type CompiledMatch, } from './matcher.js'; -import { - projectNotifRow, - resolveNotifFetch, - type NotifFetchScope, -} from './notifFetch.js'; +import { projectNotifRow, resolveNotifFetch } from './notifFetch.js'; import { lookupFsSubject, lookupKvSubject, @@ -1398,7 +1394,7 @@ export class EventsService extends PuterService { }); const page = rows.slice(0, limit); - const visible = await this.#visibleNotifications(actor, page, scope); + const visible = await this.#visibleNotifications(actor, page); const last = page[page.length - 1]; return { @@ -1414,24 +1410,28 @@ export class EventsService extends PuterService { * caller's own mailbox. A row the actor may not see is dropped rather than * refused: which notifications exist is not something an app token gets to * probe for. + * + * Ownership is a per-row fact rather than one shared answer: an unscoped + * page (a session's own generic fetch) can carry `developer` rows about + * several apps at once. */ async #visibleNotifications( actor: Actor, rows: Array>, - scope: NotifFetchScope, ): Promise>> { if (rows.length === 0) return rows; - const ownsApp = - scope.audience === 'developer' && scope.appUid - ? await this.#recipientOwnsApp( - Number(actor.user?.id), - scope.appUid, - ) - : false; + const scopes = rows.map(notificationRowScope); + const owned = await ownedAppUids( + this.stores.app, + Number(actor.user?.id), + scopes.flatMap((s) => + s.audience === 'developer' && s.appUid ? [s.appUid] : [], + ), + ); - return rows.filter((row) => - canViewNotification(notificationRowScope(row), actor, { - recipientOwnsApp: ownsApp, + return rows.filter((_row, i) => + canViewNotification(scopes[i], actor, { + recipientOwnsApp: owned.has(scopes[i].appUid ?? ''), }), ); } diff --git a/src/backend/services/events/anchors.test.ts b/src/backend/services/events/anchors.test.ts index 2ad883c4a..904a49c86 100644 --- a/src/backend/services/events/anchors.test.ts +++ b/src/backend/services/events/anchors.test.ts @@ -20,7 +20,11 @@ import { describe, expect, it } from 'vitest'; import { HttpError } from '../../core/http/HttpError.js'; import type { FSEntry } from '../../stores/fs/FSEntry.js'; -import { resolveFsAnchor, type FsAnchorDeps } from './anchors.js'; +import { + resolveFsAnchor, + resolveNotifAnchor, + type FsAnchorDeps, +} from './anchors.js'; import { compileMatch, relativeTo } from './matcher.js'; import { parseSubject } from './subjects.js'; @@ -194,3 +198,60 @@ describe('anchor and match together', () => { expect(relativeTo(anchor.path, '/alice/Desktop/x.png')).toBeNull(); }); }); + +describe('resolveNotifAnchor', () => { + const USER = 'user-uuid-alice'; + const APP = 'app-uuid-widget'; + + it('widens a session\'s own developer/app-user slice to any app', () => { + for (const audience of ['developer', 'app-user'] as const) { + const anchor = resolveNotifAnchor(parseSubject(`notif:${audience}`), { + userUuid: USER, + appUid: null, + }); + expect(anchor.appScoped).toBe(false); + expect(anchor.anyApp).toBe(true); + expect(anchor.match).toBe(`*:${audience}`); + + const compiled = compileMatch(anchor.match, { separator: ':' }); + // Some other app's row, and a row naming none at all — both are + // this session's own mailbox to hear about. + expect(compiled.test(`${APP}:${audience}`)).toBe(true); + expect(compiled.test(`${USER}:${audience}`)).toBe(true); + } + }); + + it('never widens account — it never names an app to widen across', () => { + const anchor = resolveNotifAnchor(parseSubject('notif:account'), { + userUuid: USER, + appUid: null, + }); + expect(anchor.anyApp).toBe(false); + expect(anchor.match).toBe(`${USER}:account`); + }); + + it('keeps an app\'s own two-segment slice pinned to itself', () => { + const anchor = resolveNotifAnchor(parseSubject('notif:developer'), { + userUuid: USER, + appUid: APP, + }); + expect(anchor.appScoped).toBe(true); + expect(anchor.anyApp).toBe(false); + expect(anchor.match).toBe(`${APP}:developer`); + + const compiled = compileMatch(anchor.match, { separator: ':' }); + expect(compiled.test(`${APP}:developer`)).toBe(true); + // Not this app: an app's generic subject never reaches another's rows. + expect(compiled.test('some-other-app:developer')).toBe(false); + }); + + it('keeps an explicitly named app pinned to just that one', () => { + const anchor = resolveNotifAnchor( + parseSubject(`notif:${APP}:developer`), + { userUuid: USER, appUid: null }, + ); + expect(anchor.appScoped).toBe(true); + expect(anchor.anyApp).toBe(false); + expect(anchor.match).toBe(`${APP}:developer`); + }); +}); diff --git a/src/backend/services/events/anchors.ts b/src/backend/services/events/anchors.ts index d46c3609c..580695d3b 100644 --- a/src/backend/services/events/anchors.ts +++ b/src/backend/services/events/anchors.ts @@ -207,6 +207,15 @@ export interface ResolvedNotifAnchor { subject: string; /** True when the ref names an app rather than the recipient. */ appScoped: boolean; + /** + * True for a session's own generic slice: no app named, no app context + * either. The audience predicate already grants every row of `audience` + * addressed to this recipient regardless of which app it names (a + * `developer` row once its owner is rechecked, an `app-user` row + * unconditionally), so `ref` cannot pin the filter to one app the caller + * never named — an `account` row never names an app, so it never widens. + */ + anyApp: boolean; } /** @@ -229,13 +238,15 @@ export function resolveNotifAnchor( const ref = anchorRef.ref ?? actor.appUid ?? actor.userUuid; const appScoped = ref !== actor.userUuid; + const anyApp = !appScoped && anchorRef.audience !== 'account'; return { token: notifAnchorToken(actor.userUuid), ref, audience: anchorRef.audience, - match: notifMatchOn(ref, anchorRef.audience), + match: notifMatchOn(anyApp ? '*' : ref, anchorRef.audience), subject: `notif:${ref}:${anchorRef.audience}`, appScoped, + anyApp, }; } diff --git a/src/backend/services/events/notifFetch.test.ts b/src/backend/services/events/notifFetch.test.ts index 3809ac425..f6dc8d04b 100644 --- a/src/backend/services/events/notifFetch.test.ts +++ b/src/backend/services/events/notifFetch.test.ts @@ -50,6 +50,24 @@ describe('resolveNotifFetch', () => { }); }); + it('spans every app for a session\'s own developer/app-user slice', () => { + // Neither audience falls back to the recipient the way `account` + // does, so pinning to `null` would ask for rows that structurally + // cannot exist — the fetch has to span every app instead. + for (const audience of ['developer', 'app-user']) { + expect( + resolveNotifFetch(`notif:${audience}`, { + userUuid: USER, + appUid: null, + }), + ).toEqual({ + subject: `notif:${USER}:${audience}`, + audience, + appUid: undefined, + }); + } + }); + it('reads a fully qualified subject as written', () => { expect( resolveNotifFetch(`notif:${APP}:developer`, { diff --git a/src/backend/services/events/notifFetch.ts b/src/backend/services/events/notifFetch.ts index 2e7840ddc..7eb032efc 100644 --- a/src/backend/services/events/notifFetch.ts +++ b/src/backend/services/events/notifFetch.ts @@ -35,8 +35,12 @@ export interface NotifFetchScope { /** Canonical subject, after any app-relative expansion. */ subject: string; audience: NotificationAudience; - /** App the rows are about; `null` selects the rows naming no app. */ - appUid: string | null; + /** + * App the rows are about; `null` selects the rows naming no app, + * `undefined` selects every app — a session's own generic slice, where the + * audience predicate is what actually narrows the page. + */ + appUid: string | null | undefined; } export const fetchUnsupportedSubject = (family: string): HttpError => @@ -61,7 +65,11 @@ export const resolveNotifFetch = ( return { subject: anchor.subject, audience: anchor.audience, - appUid: anchor.appScoped ? anchor.ref : null, + appUid: anchor.anyApp + ? undefined + : anchor.appScoped + ? anchor.ref + : null, }; }; diff --git a/src/backend/services/events/notifications.integration.test.ts b/src/backend/services/events/notifications.integration.test.ts index 0109a6923..e514281aa 100644 --- a/src/backend/services/events/notifications.integration.test.ts +++ b/src/backend/services/events/notifications.integration.test.ts @@ -393,6 +393,99 @@ describe('notifications with the fold-in on', () => { await state.server.services.events.reapSocket(user.id, 'socket-app'); }); + it('delivers a developer row naming an app to its owner\'s own generic subscription', async () => { + const user = await makeUser(state.server); + const appUid = await makeApp(state.server, user.id); + const actor = { + user: { id: user.id, uuid: user.uuid, username: user.username }, + effectiveApp: null, + }; + + // The two-segment sugar form a session's own feed subscribes with — + // it never names the app, unlike an app subscribing to its own rows. + await state.server.services.events.subscribe( + actor as never, + 'socket-developer-any', + { subject: 'notif:developer' }, + ); + + await state.server.services.notification.notify( + [user.id], + { title: 'a handler was suspended' }, + { type: 'app.events.suspended', appUid }, + ); + await waitFor(() => state.delivered.length > 0); + await settle(); + + expect(state.delivered).toHaveLength(1); + expect(state.delivered[0].event).toMatchObject({ + audience: 'developer', + appUid, + }); + + await state.server.services.events.reapSocket( + user.id, + 'socket-developer-any', + ); + }); + + it('never widens a generic subscription to an app its holder no longer owns', async () => { + const owner = await makeUser(state.server); + const buyer = await makeUser(state.server); + const appUid = await makeApp(state.server, owner.id); + const actor = { + user: { id: owner.id, uuid: owner.uuid, username: owner.username }, + effectiveApp: null, + }; + + await state.server.services.events.subscribe( + actor as never, + 'socket-developer-transferred', + { subject: 'notif:developer' }, + ); + // Ownership moves on; the row is still addressed to the original + // owner; the delivery-time recheck is what has to catch this, since + // the match filter alone no longer pins the row to one app. + await state.server.clients.db.write( + 'UPDATE `apps` SET `owner_user_id` = ? WHERE `uid` = ?', + [buyer.id, appUid], + ); + + await state.server.services.notification.notify( + [owner.id], + { title: 'a handler was suspended' }, + { type: 'app.events.suspended', appUid }, + ); + await settle(); + expect(state.delivered).toHaveLength(0); + + await state.server.services.events.reapSocket( + owner.id, + 'socket-developer-transferred', + ); + }); + + it('replays a developer row naming an app through a generic fetch', async () => { + const user = await makeUser(state.server); + const appUid = await makeApp(state.server, user.id); + const actor = { + user: { id: user.id, uuid: user.uuid, username: user.username }, + effectiveApp: null, + }; + + await state.server.services.notification.notify( + [user.id], + { title: 'your worker deploy failed' }, + { type: 'app.worker.deployFailed', appUid }, + ); + + const page = await state.server.services.events.fetchMissed( + actor as never, + { subject: 'notif:developer', limit: 50 }, + ); + expect(page.items.map((i) => i.appUid)).toContain(appUid); + }); + it('refuses a mailbox slice the actor could never be shown', async () => { const owner = await makeUser(state.server); const other = await makeUser(state.server); diff --git a/src/backend/services/homepage/PuterHomepageService.test.ts b/src/backend/services/homepage/PuterHomepageService.test.ts index 699039956..ca7e57070 100644 --- a/src/backend/services/homepage/PuterHomepageService.test.ts +++ b/src/backend/services/homepage/PuterHomepageService.test.ts @@ -233,6 +233,33 @@ describe('PuterHomepageService — gui() parameters', () => { ).toEqual({ login: true, signup: true }); }); + it('advertises notification events only with the fold-in switched on', async () => { + expect( + guiParamsOf(await render(makeService())).eventsNotifications, + ).toBe(false); + expect( + guiParamsOf( + await render(makeService({ events: { enabled: true } })), + ).eventsNotifications, + ).toBe(false); + expect( + guiParamsOf( + await render( + makeService({ events: { notificationsFoldIn: true } }), + ), + ).eventsNotifications, + ).toBe(false); + expect( + guiParamsOf( + await render( + makeService({ + events: { enabled: true, notificationsFoldIn: true }, + }), + ), + ).eventsNotifications, + ).toBe(true); + }); + it('disables temp users when signup is off or the operator asked for it', async () => { expect( guiParamsOf(await render(makeService())).disable_temp_users, diff --git a/src/backend/services/homepage/PuterHomepageService.ts b/src/backend/services/homepage/PuterHomepageService.ts index 01245d6b1..df54cbc7c 100644 --- a/src/backend/services/homepage/PuterHomepageService.ts +++ b/src/backend/services/homepage/PuterHomepageService.ts @@ -22,6 +22,7 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; import type { Request, Response } from 'express'; import { PuterService } from '../types.js'; +import { notificationsFoldInEnabled } from '../notification/notificationSocket.js'; import type { Actor } from '../../core/actor'; interface Manifest { @@ -242,6 +243,10 @@ export class PuterHomepageService extends PuterService { this.config.gui_puterjs_bundle ?? 'https://js.puter.com/v2/', asset_dir: assetDir, captchaRequired, + // Whether notifications are dispatched through the events layer, + // and so whether the GUI may take them from `puter.events` + // instead of the socket wire. + eventsNotifications: notificationsFoldInEnabled(this.config), ...meta, launch_options: launchOptions, }; diff --git a/src/backend/services/notification/notificationSocket.ts b/src/backend/services/notification/notificationSocket.ts index 303d8fdc0..cb04893c7 100644 --- a/src/backend/services/notification/notificationSocket.ts +++ b/src/backend/services/notification/notificationSocket.ts @@ -36,6 +36,11 @@ import type { IConfig } from '../../types.js'; * and never sibling nodes, so each region sends exactly once, and it is * outside `outer.gui.*` so the notification does not also ride the fan the * GUI mutation events do. One socket, one copy. + * + * The GUI reads notifications from `puter.events` wherever the fold-in is + * advertised (`gui_params.eventsNotifications`) and falls back to this wire + * when that lapses, so the adapter is what keeps older clients and the fallback + * working. It goes when the flag is on everywhere. */ export type NotifWire = 'notif.message' | 'notif.unreads' | 'notif.ack'; diff --git a/src/backend/stores/notification/NotificationStore.js b/src/backend/stores/notification/NotificationStore.js index debbb98e1..78b85d6a8 100644 --- a/src/backend/stores/notification/NotificationStore.js +++ b/src/backend/stores/notification/NotificationStore.js @@ -77,9 +77,12 @@ export class NotificationStore extends PuterStore { const placeholders = scope.audiences.map(() => '?').join(', '); where.push(`\`audience\` IN (${placeholders})`); params.push(...scope.audiences); + // `undefined` is "any app" — a session's own generic slice, left + // unfiltered here because the caller applies the audience + // predicate over the page afterwards. if (scope.appUid === null) { where.push('`app_uid` IS NULL'); - } else { + } else if (scope.appUid !== undefined) { where.push('`app_uid` = ?'); params.push(scope.appUid); } @@ -104,12 +107,14 @@ export class NotificationStore extends PuterStore { * rows arrive between requests. * * `appUid` is matched, not filtered afterwards — `null` means the rows - * about no app, which is not the same question as "any app". + * about no app, which is not the same question as "any app". `undefined` is + * "any app": a session's own generic slice, which spans every app the + * audience predicate already grants it rather than one named ref. * * @param {number} userId * @param {{ * audience: string; - * appUid: string | null; + * appUid?: string | null; * after?: number | null; * limit?: number; * }} opts @@ -123,7 +128,7 @@ export class NotificationStore extends PuterStore { const params = [userId, audience]; if (appUid === null) { where.push('`app_uid` IS NULL'); - } else { + } else if (appUid !== undefined) { where.push('`app_uid` = ?'); params.push(appUid); } diff --git a/src/backend/stores/notification/NotificationStore.test.js b/src/backend/stores/notification/NotificationStore.test.js index 8d30770d7..a1e28e5b4 100644 --- a/src/backend/stores/notification/NotificationStore.test.js +++ b/src/backend/stores/notification/NotificationStore.test.js @@ -264,6 +264,15 @@ describe('NotificationStore', () => { scope: { audiences: [], appUid: null }, }), ).toEqual([]); + // `undefined` is "any app" — a session's own generic slice, which + // spans both the named app and the unattributed row. + expect( + uids( + await store.listByUserId(u.id, { + scope: { audiences: ['app-user'], appUid: undefined }, + }), + ), + ).toEqual([mine.uid, unattributed.uid].sort()); }); it('ignores an unrecognised filter and returns everything', async () => { @@ -289,6 +298,48 @@ describe('NotificationStore', () => { ); }); + // -- scoped replay pages -------------------------------------------- + + it('scopes a replay page to one app, to no app, or to any app', async () => { + const u = await makeUser(); + const appUid = `app-${uuidv4()}`; + const mine = await store.create({ + userId: u.id, + value: {}, + audience: 'developer', + appUid, + }); + const unattributed = await store.create({ + userId: u.id, + value: {}, + audience: 'developer', + }); + + const uids = (rows) => rows.map((r) => r.uid).sort(); + + expect( + uids(await store.listScoped(u.id, { audience: 'developer', appUid })), + ).toEqual([mine.uid]); + // `null` is the rows naming no app, not "any app". + expect( + uids( + await store.listScoped(u.id, { + audience: 'developer', + appUid: null, + }), + ), + ).toEqual([unattributed.uid]); + // `undefined` is a session's own generic slice: every app at once. + expect( + uids( + await store.listScoped(u.id, { + audience: 'developer', + appUid: undefined, + }), + ), + ).toEqual([mine.uid, unattributed.uid].sort()); + }); + // -- mutations ----------------------------------------------------- it('acknowledges only once and only for the owning user', async () => { diff --git a/src/gui/src/UI/Dashboard/UIDashboardNotifications.js b/src/gui/src/UI/Dashboard/UIDashboardNotifications.js index 70a9d6de0..ab1c9317a 100644 --- a/src/gui/src/UI/Dashboard/UIDashboardNotifications.js +++ b/src/gui/src/UI/Dashboard/UIDashboardNotifications.js @@ -20,6 +20,7 @@ import UINotification from '../UINotification.js'; import { reveal_dashboard } from '../UIWindow.js'; import { listNotifications, markNotificationAcknowledged } from '../../helpers/notificationApi.js'; +import { applyToastMark, createNotificationFeed } from '../../helpers/notificationFeed.js'; import { badgeLabel, formatAbsoluteTime, @@ -457,7 +458,7 @@ export default function UIDashboardNotifications ({ $el_window, socket }) { // A toast is the one control that reaches the user while an app window // covers the dashboard; what it leads to happens on the dashboard, so // that has to come back into view first. - const toast = (entry) => { + const toast = (entry, { replay = false } = {}) => { const { notification } = entry; UINotification({ uid: entry.uid, @@ -470,6 +471,11 @@ export default function UIDashboardNotifications ({ $el_window, socket }) { // The ✕ is a dismissal; timing out is not, so `close` alone acks. close: () => void markRead(entry.uid), }); + // Shown is not dismissed: the events path can say so, and a replay + // already claimed it on the way in. + if ( ! replay ) { + void applyToastMark('shown', entry.uid, { eventsPath: feed.isActive() }); + } }; const toastSummary = (count) => { @@ -496,7 +502,7 @@ export default function UIDashboardNotifications ({ $el_window, socket }) { * Fold arrivals in. While the panel is open they simply appear in it; * closed, each gets a toast — a large burst a summary instead. */ - const receive = (rawItems) => { + const receive = (rawItems, { replay = false } = {}) => { const now = Date.now(); const incoming = rawItems.map((raw) => toEntry(raw, now)).filter(Boolean); const result = mergeEntries(entries, incoming); @@ -519,7 +525,7 @@ export default function UIDashboardNotifications ({ $el_window, socket }) { // Open, the panel is already showing them. if ( isOpen ) return; const { shown, folded } = planBurstToasts(fresh); - for ( const entry of [...shown].reverse() ) toast(entry); + for ( const entry of [...shown].reverse() ) toast(entry, { replay }); if ( folded > 0 ) toastSummary(folded); }; @@ -669,10 +675,31 @@ export default function UIDashboardNotifications ({ $el_window, socket }) { }).observe(titleEl, { childList: true, characterData: true, subtree: true }); } - // -- Socket ------------------------------------------------------------------- + // -- Arrivals ----------------------------------------------------------------- - socket.on('notif.message', ({ uid, notification }) => receive([{ uid, notification }])); - socket.on('notif.unreads', ({ unreads }) => receive(Array.isArray(unreads) ? unreads : [])); + /** + * Notifications over the events surface, where the server says it has + * them. Arrivals fold in exactly as the socket's do; the listeners below + * stand down only while it is up, so a lapse is a fallback rather than + * silence. + */ + const feed = createNotificationFeed({ + deliver: (items, { replay }) => receive(items, { replay }), + }); + void feed.start(); + + socket.on('notif.message', ({ uid, notification }) => { + if ( feed.isActive() ) return; + receive([{ uid, notification }]); + }); + socket.on('notif.unreads', ({ unreads }) => { + if ( feed.isActive() ) return; + receive(Array.isArray(unreads) ? unreads : []); + }); + // Not gated: an ack marks an entry read rather than adding one, so it + // can't duplicate anything — and the events surface carries only + // postings, which leaves this the one thing that syncs a dismissal + // between open tabs. socket.on('notif.ack', ({ uid }) => { if ( ! uid ) return; $(`.notification[data-uid="${html_encode(uid)}"]`).closest('.notification-wrapper').remove(); diff --git a/src/gui/src/UI/UIDesktop.js b/src/gui/src/UI/UIDesktop.js index 78f170276..242038421 100644 --- a/src/gui/src/UI/UIDesktop.js +++ b/src/gui/src/UI/UIDesktop.js @@ -43,7 +43,7 @@ import launch_app from '../helpers/launchApp.js'; import item_icon from '../helpers/itemIcon.js'; import { SHARED_PATH_PARAM, clear_shared_param } from '../helpers/parseSharedPath.js'; import resolve_shared_item from '../helpers/resolveSharedItem.js'; -import { markNotificationAcknowledged } from '../helpers/notificationApi.js'; +import { applyToastMark, createNotificationFeed } from '../helpers/notificationFeed.js'; import { notificationTarget } from './Dashboard/notificationCenter.js'; import apply_item_added_to_containers from '../helpers/applyItemAddedToContainers.js'; import UIWindowSearch from './UIWindowSearch.js'; @@ -238,12 +238,11 @@ async function UIDesktop (options) { }; /** - * This event is triggered if a user receives a notification during - * an active session. + * Raise one notification, whichever path it arrived on. `replay` names + * what the feed already claimed as shown while catching up, so the same + * row isn't marked twice. */ - window.socket.on('notif.message', async ({ uid, notification }) => { - let icon = window.icons[notification.icon]; - + const show_notification = ({ uid, notification }, { replay = false } = {}) => { // A notification can be re-sent under its own uid when what it says has // grown — several people sharing with you is one notification that // counts them. Refresh the one on screen rather than stacking a copy. @@ -257,14 +256,39 @@ async function UIDesktop (options) { UINotification({ title: notification.title, text: notification.text, - icon: icon, + icon: window.icons[notification.icon], value: notification, uid, click: share_notification_click(notification), - close: () => markNotificationAcknowledged(uid).catch((err) => { - console.warn('Could not acknowledge notification:', err); + close: () => applyToastMark('dismissed', uid, { + eventsPath: notification_feed.isActive(), }), }); + + if ( ! replay ) { + void applyToastMark('shown', uid, { eventsPath: notification_feed.isActive() }); + } + }; + + /** + * Notifications over the events surface, where the server says it has + * them. It renders through the same path the socket wire does, and the + * listeners below stand down only while it is up. + */ + const notification_feed = createNotificationFeed({ + deliver: (items, { replay }) => { + for ( const item of items ) show_notification(item, { replay }); + }, + }); + void notification_feed.start(); + + /** + * This event is triggered if a user receives a notification during + * an active session. + */ + window.socket.on('notif.message', async ({ uid, notification }) => { + if ( notification_feed.isActive() ) return; + show_notification({ uid, notification }); }); /** @@ -277,27 +301,24 @@ async function UIDesktop (options) { */ window.__already_got_unreads = false; window.socket.on('notif.unreads', async ({ unreads }) => { + if ( notification_feed.isActive() ) return; if ( window.__already_got_unreads ) return; window.__already_got_unreads = true; for ( const notif_info of unreads ) { const notification = notif_info.notification; - let icon = window.icons[notification.icon]; - - UINotification({ - icon, - title: notification.title, - text: notification.text ?? notification.title, + show_notification({ uid: notif_info.uid, - value: notification, - click: share_notification_click(notification), - close: () => markNotificationAcknowledged(notif_info.uid).catch((err) => { - console.warn('Could not acknowledge notification:', err); - }), - }); + // The replay has always shown the title as the body for a + // notification carrying no text of its own. + notification: { ...notification, text: notification.text ?? notification.title }, + }, { replay: true }); } }); + // Not gated: an ack takes a toast off screen rather than raising one, so + // it can't double-render — and the events surface carries only postings, + // which leaves this the one thing that still dismisses across tabs. window.socket.on('notif.ack', ({ uid }) => { $(`.notification[data-uid="${uid}"]`).remove(); update_tab_notif_count_badge(); diff --git a/src/gui/src/helpers/notificationApi.js b/src/gui/src/helpers/notificationApi.js index 9cf1fdd84..a9e9bbaf4 100644 --- a/src/gui/src/helpers/notificationApi.js +++ b/src/gui/src/helpers/notificationApi.js @@ -47,6 +47,36 @@ export async function markNotificationAcknowledged (uid) { } } +/** + * Claim a notification as shown. Distinct from dismissing it: the mailbox + * records that it reached someone, which is what keeps a replay from raising + * it in every tab. Resolves `true` only for the client that got there first, + * and `false` — never a rejection — when it was already shown or the call + * failed, since nothing about a toast is worth failing over. + * + * @param {string} uid + * @returns {Promise} + */ +export async function markNotificationShown (uid) { + try { + const res = await fetch(`${window.api_origin}/drivers/call`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ + interface: 'puter-notifications', + driver: 'es:notification', + method: 'mark_shown', + args: { uid }, + }), + }); + if ( ! res.ok ) return false; + const body = await res.json(); + return body?.success !== false && body?.result?.success === true; + } catch { + return false; + } +} + /** * @typedef {Object} NotificationRow * @property {string} uid diff --git a/src/gui/src/helpers/notificationFeed.js b/src/gui/src/helpers/notificationFeed.js new file mode 100644 index 000000000..465b28267 --- /dev/null +++ b/src/gui/src/helpers/notificationFeed.js @@ -0,0 +1,315 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Notifications over `puter.events`, where the server says it has them. + * + * The desktop and the dashboard have always rendered from the socket wire + * (`notif.message` / `notif.unreads`). Where the events surface is + * advertised, this feed renders instead: one subscription per slice of the + * mailbox a session can name, and a fetch for what arrived while nothing was + * listening. The socket listeners stay registered either way and ask + * `isActive()` before rendering, so a lapsed subscription hands rendering + * straight back to them rather than leaving the GUI deaf. + */ + +import { + listNotifications, + markNotificationAcknowledged, + markNotificationShown, +} from './notificationApi.js'; + +/** + * Every slice of their own mailbox a user session can name: `notif:` + * expands against the session, which covers the rows naming no app. Rows about + * an app are addressed by its uid and are that app's own to watch. + */ +export const NOTIFICATION_SUBJECTS = Object.freeze([ + 'notif:account', + 'notif:developer', + 'notif:app-user', +]); + +/** + * Notifications one page of a replay reads — the server's own cap, since a + * fetch with no cursor starts at the oldest notification still kept and the + * interesting ones are at the far end. + */ +const REPLAY_LIMIT = 200; + +/** + * Pages one replay walks. A mailbox deeper than this leaves its oldest + * behind, which the notification center lists anyway. + */ +const REPLAY_PAGES = 3; + +/** How far back the read-state filter looks. */ +const UNACKNOWLEDGED_LIMIT = 200; + +/** + * @typedef {Object} FeedNotification + * @property {string} uid + * @property {Object} notification - The payload, as the socket wire carries it + * @property {number} created_at - Epoch milliseconds + */ + +/** + * Whether the events path is usable: the server advertises it and the loaded + * SDK has the surface. An older cached bundle is the reason for the second + * half — the capability says what the backend can do, not what this page can + * call. + * + * @param {Object} [params] - `window.gui_params` + * @param {Object} [sdk] - `window.puter` + * @returns {boolean} + */ +export const eventsNotificationsAvailable = (params, sdk) => ( + params?.eventsNotifications === true + && typeof sdk?.events?.onLocal === 'function' + && typeof sdk?.events?.fetch === 'function' +); + +/** + * One event projection in the shape the render paths take. The payload rides + * the projection verbatim, so this only unwraps it — and folds in the + * registry `type` where the payload predates it, since the predicates read + * `type` first. + * + * `null` for anything that isn't a notification: a gap marker, or an event + * with no uid to acknowledge it by. + * + * @param {Object} event + * @returns {FeedNotification|null} + */ +export const notificationFromEvent = (event) => { + if ( ! event || typeof event !== 'object' ) return null; + if ( event.op !== 'post' ) return null; + const uid = event.uid ?? event.id; + if ( typeof uid !== 'string' || uid === '' ) return null; + const payload = event.notification; + const notification = payload && typeof payload === 'object' ? payload : {}; + const type = notification.type ?? (event.type || undefined); + return { + uid, + notification: type === undefined ? notification : { ...notification, type }, + created_at: Number.isFinite(event.ts) ? event.ts : Date.now(), + }; +}; + +/** + * What a replay should raise: the fetched events that are still undismissed + * and haven't already come through live, in the order they were created. A + * uid seen twice keeps its newest copy — the backend re-sends a notification + * under its own uid when what it says has grown. + * + * @param {Object[]} events + * @param {{ unacknowledged: Set, delivered?: Set }} state + * @returns {FeedNotification[]} + */ +export const replayCandidates = (events, { unacknowledged, delivered = new Set() }) => { + /** @type {Map} */ + const byUid = new Map(); + for ( const event of events ?? [] ) { + const item = notificationFromEvent(event); + if ( ! item ) continue; + if ( ! unacknowledged.has(item.uid) || delivered.has(item.uid) ) continue; + byUid.delete(item.uid); + byUid.set(item.uid, item); + } + return [...byUid.values()]; +}; + +/** + * What a toast's lifecycle step records on the mailbox. Shown and dismissed + * are one thing on the socket wire — both arrive as an ack, which means "take + * it off screen" — so only the events path marks a rendering as shown. + * + * @param {'shown'|'dismissed'} phase + * @param {{ eventsPath?: boolean }} [opts] + * @returns {'shown'|'ack'|null} + */ +export const markForToast = (phase, { eventsPath = false } = {}) => { + if ( phase === 'dismissed' ) return 'ack'; + if ( phase === 'shown' ) return eventsPath ? 'shown' : null; + return null; +}; + +/** + * Record a toast's lifecycle step on the mailbox, per {@link markForToast}. + * Never rejects: nothing about a toast is worth failing a render over. + * + * @param {'shown'|'dismissed'} phase + * @param {string} uid + * @param {{ eventsPath?: boolean, api?: { shown: Function, ack: Function } }} [opts] + * @returns {Promise} + */ +export const applyToastMark = async (phase, uid, { eventsPath = false, api = {} } = {}) => { + const mark = markForToast(phase, { eventsPath }); + if ( mark === null ) return; + try { + if ( mark === 'ack' ) await (api.ack ?? markNotificationAcknowledged)(uid); + else await (api.shown ?? markNotificationShown)(uid); + } catch (err) { + console.warn(`Could not mark notification ${mark}:`, err); + } +}; + +/** The uids of everything the user has not dismissed. */ +const undismissedUids = async () => { + const rows = await listNotifications({ + predicate: 'unacknowledged', + limit: UNACKNOWLEDGED_LIMIT, + }); + return new Set(rows.map((row) => row.uid).filter(Boolean)); +}; + +/** + * The notification feed for one page. + * + * `deliver` is handed notifications in the socket wire's shape, so the + * renderers are the ones already written against it. Every dependency is + * injectable because the interesting parts — what replays, what is dropped as + * a duplicate, when the socket takes over again — are worth testing without a + * desktop around them. + * + * @param {Object} opts + * @param {(items: FeedNotification[], meta: { replay: boolean }) => void} opts.deliver + * @param {Object} [opts.sdk] - `window.puter` + * @param {Object} [opts.params] - `window.gui_params` + * @param {{ undismissed?: () => Promise>, claimShown?: (uid: string) => Promise }} [opts.mailbox] + * @returns {{ isActive: () => boolean, start: () => Promise, stop: () => Promise }} + */ +export function createNotificationFeed ({ + deliver, + sdk = globalThis.puter, + params = globalThis.gui_params, + mailbox = {}, +} = {}) { + const undismissed = mailbox.undismissed ?? undismissedUids; + const claimShown = mailbox.claimShown ?? markNotificationShown; + + let active = false; + /** @type {Object[]} */ + let subscriptions = []; + /** Uids this page has already rendered, so a replay never repeats one. */ + const delivered = new Set(); + /** How far each subject has been read, by subject. */ + const cursors = new Map(); + + const hand = (items, replay) => { + if ( items.length === 0 ) return; + for ( const item of items ) delivered.add(item.uid); + deliver(items, { replay }); + }; + + // A live arrival is marked shown by whoever renders it — see + // `applyToastMark` — so nothing is claimed here. + const onDelivery = (event) => { + const item = notificationFromEvent(event); + if ( item ) hand([item], false); + }; + + /** + * One subject's slice, from where the last read left off. A page comes + * back without a cursor when it is the end of what there is, so a later + * catch-up re-reads the tail rather than the whole mailbox. + */ + const read = async (subject) => { + const items = []; + try { + for ( let page = 0; page < REPLAY_PAGES; page++ ) { + const after = cursors.get(subject); + const result = await sdk.events.fetch({ + subject, + limit: REPLAY_LIMIT, + ...(after ? { after } : {}), + }); + items.push(...(result?.items ?? [])); + if ( typeof result?.cursor !== 'string' ) break; + cursors.set(subject, result.cursor); + } + } catch (err) { + console.warn(`Could not read missed notifications for ${subject}:`, err); + } + return items; + }; + + /** + * What arrived while nothing was listening. The mailbox decides what is + * still worth raising twice over: undismissed, and claimed here first — + * `mark-shown` only succeeds for whoever gets there first, which is what + * keeps a notification from being toasted once per open tab. + */ + const replay = async () => { + const [pages, unacknowledged] = await Promise.all([ + Promise.all(NOTIFICATION_SUBJECTS.map(read)), + undismissed(), + ]); + + for ( const item of replayCandidates(pages.flat(), { unacknowledged, delivered }) ) { + const claimed = await claimShown(item.uid).catch(() => false); + if ( claimed ) hand([item], true); + } + }; + + /** The subscription is gone; the socket listeners render from here on. */ + const lapse = (err) => { + if ( ! active ) return; + console.warn('Notification events lapsed, falling back to the socket:', err); + active = false; + void stop(); + // Whatever the lapse swallowed is still in the mailbox, and the + // socket only pushes what happens next. + void replay().catch(() => {}); + }; + + const start = async () => { + if ( active ) return true; + if ( ! eventsNotificationsAvailable(params, sdk) ) return false; + + const results = await Promise.allSettled(NOTIFICATION_SUBJECTS.map((subject) => ( + sdk.events.onLocal(subject, ({ event }) => onDelivery(event), { onError: lapse }) + ))); + // Held even when a sibling failed: a subscription nothing can turn + // off would keep rendering behind the socket listeners' back. + subscriptions = results.filter((r) => r.status === 'fulfilled').map((r) => r.value); + const refused = results.find((r) => r.status === 'rejected'); + if ( refused ) { + console.warn('Could not subscribe to notification events:', refused.reason); + await stop(); + return false; + } + // Subscribed before replaying, so nothing lands in the gap between + // the two. + active = true; + void replay().catch((err) => { + console.warn('Could not replay missed notifications:', err); + }); + return true; + }; + + const stop = async () => { + active = false; + const held = subscriptions; + subscriptions = []; + await Promise.all(held.map((sub) => sub?.off?.())); + }; + + return { isActive: () => active, start, stop }; +} diff --git a/src/gui/src/helpers/notificationFeed.test.js b/src/gui/src/helpers/notificationFeed.test.js new file mode 100644 index 000000000..09db80252 --- /dev/null +++ b/src/gui/src/helpers/notificationFeed.test.js @@ -0,0 +1,361 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { afterEach, describe, it, expect, vi } from 'vitest'; +import { + NOTIFICATION_SUBJECTS, + applyToastMark, + createNotificationFeed, + eventsNotificationsAvailable, + markForToast, + notificationFromEvent, + replayCandidates, +} from './notificationFeed.js'; + +const TS = Date.UTC(2026, 7, 27, 12, 0, 0); + +const event = (uid, over = {}) => ({ + id: uid, + subject: 'notif:account', + op: 'post', + uid, + type: 'share.received', + audience: 'account', + appUid: null, + notification: { title: 'Shared with you' }, + self: true, + ts: TS, + seq: 0, + ...over, +}); + +/** Let everything the feed kicked off without awaiting finish. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +/** An SDK whose subscriptions and fetches are answered from `opts`. */ +const fakeSdk = ({ pages = {}, onSubscribe } = {}) => { + const subs = []; + return { + subs, + events: { + onLocal: vi.fn(async (subject, handler, options) => { + if ( onSubscribe ) await onSubscribe(subject); + const sub = { subject, handler, options, off: vi.fn(async () => {}) }; + subs.push(sub); + return sub; + }), + fetch: vi.fn(async ({ subject }) => ({ items: pages[subject] ?? [] })), + }, + }; +}; + +const startFeed = async ({ sdk, mailbox = {}, params = { eventsNotifications: true } }) => { + const delivered = []; + const feed = createNotificationFeed({ + deliver: (items, meta) => delivered.push([items, meta]), + sdk, + params, + mailbox: { + undismissed: async () => new Set(), + claimShown: async () => true, + ...mailbox, + }, + }); + const started = await feed.start(); + await settle(); + return { feed, delivered, started }; +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('eventsNotificationsAvailable', () => { + const sdk = { events: { onLocal: () => {}, fetch: () => {} } }; + + it('needs the server to advertise it', () => { + expect(eventsNotificationsAvailable({}, sdk)).toBe(false); + expect(eventsNotificationsAvailable(undefined, sdk)).toBe(false); + expect(eventsNotificationsAvailable({ eventsNotifications: 'yes' }, sdk)).toBe(false); + expect(eventsNotificationsAvailable({ eventsNotifications: true }, sdk)).toBe(true); + }); + + it('needs the loaded SDK to have the surface', () => { + const params = { eventsNotifications: true }; + expect(eventsNotificationsAvailable(params, {})).toBe(false); + expect(eventsNotificationsAvailable(params, { events: {} })).toBe(false); + expect(eventsNotificationsAvailable(params, { events: { onLocal: () => {} } })).toBe(false); + }); +}); + +describe('notificationFromEvent', () => { + it('hands the payload over as the wire carries it', () => { + const payload = { title: 'Shared with you', text: 'report.pdf', fields: { count: 1 } }; + expect(notificationFromEvent(event('n1', { notification: payload }))).toEqual({ + uid: 'n1', + notification: { ...payload, type: 'share.received' }, + created_at: TS, + }); + }); + + it('leaves a payload that names its own type alone', () => { + const payload = { title: 'x', type: 'share.claimed' }; + expect(notificationFromEvent(event('n1', { notification: payload })).notification.type) + .toBe('share.claimed'); + }); + + it('adds no type when the event has none either', () => { + const item = notificationFromEvent(event('n1', { type: '', notification: { title: 'x' } })); + expect(item.notification).toEqual({ title: 'x' }); + }); + + it('is nothing for a gap marker or an event with no uid', () => { + expect(notificationFromEvent({ id: 'g1', op: 'gap', reason: 'delivery_rate_limit' })).toBe(null); + expect(notificationFromEvent(event(undefined, { id: undefined }))).toBe(null); + expect(notificationFromEvent(null)).toBe(null); + expect(notificationFromEvent('notif')).toBe(null); + }); + + it('falls back to the event id when the projection names no uid', () => { + expect(notificationFromEvent({ id: 'n9', op: 'post', notification: {} }).uid).toBe('n9'); + }); +}); + +describe('replayCandidates', () => { + const unacknowledged = new Set(['n1', 'n2']); + + it('keeps only what is still undismissed, in the order it arrived', () => { + const out = replayCandidates( + [event('n1'), event('gone'), event('n2')], + { unacknowledged }, + ); + expect(out.map((item) => item.uid)).toEqual(['n1', 'n2']); + }); + + it('skips what already came through live', () => { + const out = replayCandidates( + [event('n1'), event('n2')], + { unacknowledged, delivered: new Set(['n1']) }, + ); + expect(out.map((item) => item.uid)).toEqual(['n2']); + }); + + it('keeps the newest copy of a uid sent twice, at its later position', () => { + const out = replayCandidates( + [event('n1', { notification: { title: 'one share' } }), event('n2'), + event('n1', { notification: { title: 'two shares' } })], + { unacknowledged }, + ); + expect(out.map((item) => item.uid)).toEqual(['n2', 'n1']); + expect(out[1].notification.title).toBe('two shares'); + }); + + it('drops gap markers rather than replaying them', () => { + const out = replayCandidates([{ id: 'g', op: 'gap' }, event('n1')], { unacknowledged }); + expect(out.map((item) => item.uid)).toEqual(['n1']); + }); +}); + +describe('markForToast', () => { + it('marks a rendering shown only where the events path can say so', () => { + expect(markForToast('shown', { eventsPath: true })).toBe('shown'); + expect(markForToast('shown', { eventsPath: false })).toBe(null); + expect(markForToast('shown')).toBe(null); + }); + + it('acknowledges a dismissal on either path', () => { + expect(markForToast('dismissed', { eventsPath: true })).toBe('ack'); + expect(markForToast('dismissed', { eventsPath: false })).toBe('ack'); + }); +}); + +describe('applyToastMark', () => { + const api = () => ({ shown: vi.fn(async () => true), ack: vi.fn(async () => {}) }); + + it('routes each phase to its own call', async () => { + const calls = api(); + await applyToastMark('shown', 'n1', { eventsPath: true, api: calls }); + await applyToastMark('dismissed', 'n1', { eventsPath: true, api: calls }); + expect(calls.shown).toHaveBeenCalledWith('n1'); + expect(calls.ack).toHaveBeenCalledWith('n1'); + }); + + it('marks nothing for a rendering on the socket wire', async () => { + const calls = api(); + await applyToastMark('shown', 'n1', { eventsPath: false, api: calls }); + expect(calls.shown).not.toHaveBeenCalled(); + expect(calls.ack).not.toHaveBeenCalled(); + }); + + it('does not fail a render when the mailbox is unreachable', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const calls = { ack: vi.fn(async () => { throw new Error('offline'); }) }; + await expect(applyToastMark('dismissed', 'n1', { api: calls })).resolves.toBeUndefined(); + }); +}); + +describe('createNotificationFeed', () => { + it('stays off, and subscribes to nothing, without the capability', async () => { + const sdk = fakeSdk(); + const { feed, started } = await startFeed({ sdk, params: {} }); + expect(started).toBe(false); + expect(feed.isActive()).toBe(false); + expect(sdk.events.onLocal).not.toHaveBeenCalled(); + }); + + it('watches every slice of the mailbox a session can name', async () => { + const sdk = fakeSdk(); + const { feed } = await startFeed({ sdk }); + expect(feed.isActive()).toBe(true); + expect(sdk.subs.map((sub) => sub.subject)).toEqual([...NOTIFICATION_SUBJECTS]); + }); + + it('hands a live delivery over as an arrival, not a replay', async () => { + const sdk = fakeSdk(); + const { delivered, feed } = await startFeed({ sdk }); + sdk.subs[0].handler({ event: event('n1') }); + expect(delivered).toHaveLength(1); + expect(delivered[0][0][0].uid).toBe('n1'); + expect(delivered[0][1]).toEqual({ replay: false }); + expect(feed.isActive()).toBe(true); + }); + + it('replays only what is undismissed and claimed here first', async () => { + const sdk = fakeSdk({ pages: { 'notif:account': [event('n1'), event('n2'), event('n3')] } }); + const claimShown = vi.fn(async (uid) => uid !== 'n2'); + const { delivered } = await startFeed({ + sdk, + mailbox: { undismissed: async () => new Set(['n1', 'n2']), claimShown }, + }); + expect(delivered.map(([items]) => items[0].uid)).toEqual(['n1']); + expect(delivered[0][1]).toEqual({ replay: true }); + // n3 is dismissed, so it is never even claimed. + expect(claimShown.mock.calls.map(([uid]) => uid)).toEqual(['n1', 'n2']); + }); + + it('does not replay what it already delivered live', async () => { + const sdk = fakeSdk({ + pages: { 'notif:account': [event('n1')] }, + // Deliver live while the replay's own reads are in flight. + onSubscribe: () => {}, + }); + const feedItems = []; + const feed = createNotificationFeed({ + deliver: (items, meta) => feedItems.push([items[0].uid, meta.replay]), + sdk, + params: { eventsNotifications: true }, + mailbox: { + undismissed: async () => new Set(['n1']), + claimShown: async () => true, + }, + }); + await feed.start(); + sdk.subs[0].handler({ event: event('n1') }); + await settle(); + expect(feedItems).toEqual([['n1', false]]); + }); + + it('reads every subject when catching up', async () => { + const sdk = fakeSdk(); + await startFeed({ sdk }); + expect(sdk.events.fetch.mock.calls.map(([opts]) => opts.subject)) + .toEqual([...NOTIFICATION_SUBJECTS]); + }); + + it('follows the cursor to the end of a deep mailbox', async () => { + // A page comes back with a cursor while there is more behind it. + const pages = [ + { items: [event('n1')], cursor: 'c1' }, + { items: [event('n2')] }, + ]; + const sdk = fakeSdk(); + sdk.events.fetch = vi.fn(async ({ subject, after }) => { + if ( subject !== 'notif:account' ) return { items: [] }; + return after === 'c1' ? pages[1] : pages[0]; + }); + const { delivered } = await startFeed({ + sdk, + mailbox: { undismissed: async () => new Set(['n1', 'n2']) }, + }); + expect(delivered.map(([items]) => items[0].uid)).toEqual(['n1', 'n2']); + }); + + it('resumes a later read where the last one stopped', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const sdk = fakeSdk(); + sdk.events.fetch = vi.fn(async ({ after }) => ( + after ? { items: [] } : { items: [event('n1')], cursor: 'c1' } + )); + const { feed } = await startFeed({ + sdk, + mailbox: { undismissed: async () => new Set(['n1']) }, + }); + sdk.events.fetch.mockClear(); + + sdk.subs[0].options.onError(new Error('connection lost')); + await settle(); + expect(feed.isActive()).toBe(false); + expect(sdk.events.fetch.mock.calls[0][0].after).toBe('c1'); + }); + + it('stands down and fills the gap when a subscription lapses', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const sdk = fakeSdk({ pages: { 'notif:account': [event('n1')] } }); + // Nothing was waiting at first; `n1` lands while the subscription is + // on its way out. + const unacknowledged = new Set(); + const { feed, delivered } = await startFeed({ + sdk, + mailbox: { undismissed: async () => unacknowledged }, + }); + expect(delivered).toHaveLength(0); + + unacknowledged.add('n1'); + sdk.subs[0].options.onError(new Error('connection lost')); + await settle(); + + expect(feed.isActive()).toBe(false); + for ( const sub of sdk.subs ) expect(sub.off).toHaveBeenCalled(); + // Whatever the lapse swallowed still reaches the user. + expect(delivered.map(([items]) => items[0].uid)).toEqual(['n1']); + }); + + it('leaves the socket in charge when subscribing fails', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const sdk = fakeSdk({ + onSubscribe: (subject) => { + if ( subject === 'notif:developer' ) throw new Error('refused'); + }, + }); + const { feed, started, delivered } = await startFeed({ sdk }); + expect(started).toBe(false); + expect(feed.isActive()).toBe(false); + expect(delivered).toHaveLength(0); + // Nothing is left subscribed behind a failed start. + for ( const sub of sdk.subs ) expect(sub.off).toHaveBeenCalled(); + }); + + it('stops watching when it is torn down', async () => { + const sdk = fakeSdk(); + const { feed } = await startFeed({ sdk }); + await feed.stop(); + expect(feed.isActive()).toBe(false); + for ( const sub of sdk.subs ) expect(sub.off).toHaveBeenCalled(); + }); +});