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