/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** * NotificationStoreEntry * --- * What the platform writes to disk for each web notification — one entry * per `(origin, id)` pair under `/notificationstore.json`. * This is the raw input shape `normalizeEntry` consumes; everything * downstream is derived from it. * * @typedef {object} NotificationStoreEntry * @property {string} id * @property {string} [title] * @property {string} [body] * @property {string} [tag] * @property {string} [dir] * @property {string} [icon] * @property {boolean} [requireInteraction] * @property {number} [timestamp] */ /** * NormalizedNotification * --- * A single notification as it appears in the slice — what UI consumers * actually iterate over to render a card or count an unread. Mostly a * pass-through of `NotificationStoreEntry` with `origin` injected, because * the on-disk file keeps origin as the outer map key, not on the entry * itself, and flat consumers need to recover it. * * @typedef {object} NormalizedNotification * @property {string} id * @property {string} origin * @property {string} [title] * @property {string} [body] * @property {string} [tag] * @property {string} [dir] * @property {string} [icon] * @property {boolean} [requireInteraction] * @property {number} [timestamp] */ /** * WebNotificationsUpdatedPayload * --- * What the feed dispatches when a snapshot finishes successfully. The * reducer copies these fields onto the slice verbatim and flips * `initialized` to true. If you're a UI consumer you probably want * `WebNotificationsSlice` instead — this typedef is for the * action-handling layer (reducers, middleware, devtools introspection). * * @typedef {object} WebNotificationsUpdatedPayload * @property {number} lastUpdated * @property {{[id: string]: NormalizedNotification}} notifications * @property {{[origin: string]: string[]}} byOrigin */ /** * WebNotificationsError * --- * Carries why a snapshot didn't make it. Lands on `slice.error` so a * debug surface can show "couldn't read notifications: " * instead of throwing or silently going stale. * * @typedef {object} WebNotificationsError * @property {string} message - Stringified error. */ /** * WebNotificationsSlice * --- * The shape of `state.WebNotifications` that UI consumers actually * read. If you're writing a React component or a selector and your * code calls `useSelector(s => s.WebNotifications)`, this is the * contract you're working against. * * @typedef {object} WebNotificationsSlice * @property {boolean} initialized - True after the first successful snapshot. * @property {?number} lastUpdated - ms timestamp of the most recent snapshot. * @property {{[id: string]: NormalizedNotification}} notifications * @property {{[origin: string]: string[]}} byOrigin * @property {?WebNotificationsError} error - Last snapshot error, if any. */ import { actionCreators as ac, actionTypes as at, } from "resource://newtab/common/Actions.mjs"; // The platform persists web notifications to this file in the user's profile. // The name is a hardcoded literal in dom/notification/NotificationDB.sys.mjs; // renaming it would require profile-data migration, so it's the closest thing // to a stable contract we get without a documented API. Reading is purely an // observation — there's no file watcher, no reconciliation, no OS effect. const NOTIFICATION_STORE_FILENAME = "notificationstore.json"; // Fields we copy through from a stored entry. Everything else is // dropped to keep the IPC payload tight const PASSTHROUGH_FIELDS = [ "id", "title", "dir", "body", "tag", "icon", "requireInteraction", "timestamp", ]; /** * readNotificationStore * --- * Reads and parses the platform's persisted notification store. The platform * writes this file via atomic rename, so we never observe a torn read. A * missing file (first-run profile) resolves to an empty object. * * @returns {Promise<{[origin: string]: {[id: string]: NotificationStoreEntry}}>} */ async function readNotificationStore() { const path = PathUtils.join( PathUtils.profileDir, NOTIFICATION_STORE_FILENAME ); try { const text = await IOUtils.readUTF8(path); return text.length ? JSON.parse(text) : {}; } catch (e) { if (DOMException.isInstance(e) && e.name === "NotFoundError") { return {}; } throw e; } } /** * normalizeEntry * --- * Takes a raw on-disk entry and returns the slim shape consumers see in * the slice. We pass through the display-relevant fields, drop everything * else (page payloads, SW plumbing, anything no UI needs), and inject * `origin` because the on-disk store keeps it as the outer map key, not * on the entry itself. * * @param {NotificationStoreEntry} entry * @param {string} origin * @returns {NormalizedNotification} */ function normalizeEntry(entry, origin) { const out = { origin }; for (const key of PASSTHROUGH_FIELDS) { if (entry[key] !== undefined) { out[key] = entry[key]; } } return out; } /** * WebNotificationsFeed * --- * Feed that publishes a snapshot of the platform's persisted web * notifications into the newtab Redux slice. Reads `notificationstore.json` * directly off disk, normalizes the entries, and dispatches the result. * * Refresh convention: * - INIT seeds the slice once at feed startup and broadcasts it, so a tab * already open — and, via NewTabInit's full-state reply, every tab opened * afterwards — starts from a snapshot. * - Every later refresh is targeted at a single tab, never broadcast: the feed * re-reads disk and answers just the requesting tab with AlsoToOneContent, * which also runs the main reducer so main state stays warm as the seed for * the next new tab. Blast radius is one tab per event, not a fan-out to * every open page. * - NEW_TAB_LOAD drives a targeted refresh today: (re)loading a tab re-reads * disk for that tab, so "reload to see current counts" works without any UI. * - WEB_NOTIFICATIONS_REQUEST is the entry point content will drive A consumer * dispatches `ac.OnlyToMain({ type: at.WEB_NOTIFICATIONS_REQUEST })` on * mount and on becoming visible; the feed answers that tab only. * - A NotificationDB.save() observer is the eventual true-push that retires * the poll, but it lives in dom/notification/ (platform code, outside this * XPI), so it is non-train-hoppable and rides the release train. * * Default-off via the `feeds.webnotificationsfeed` pref */ export class WebNotificationsFeed { /** * Routes one snapshot action: to a single tab when `target` is a port id * (this also runs the main reducer, keeping main state ready for * the next new tab), or to every open tab when it is omitted. * * @param {object} action * @param {string} [target] - Port id of the tab to answer. */ _sendToTabs(action, target) { this.store.dispatch( target ? ac.AlsoToOneContent(action, target) : ac.BroadcastToContent(action) ); } /** * Snapshots the on-disk notification store and dispatches the normalized * result. INIT omits `target` to seed all tabs; every other trigger passes * the requesting tab's port so the refresh stays scoped to that one tab. * * @param {string} [target] - Port id from `action.meta.fromTarget`. * @returns {Promise} */ async _snapshot(target) { let raw; try { raw = await readNotificationStore(); } catch (e) { this._sendToTabs( { type: at.WEB_NOTIFICATIONS_ERROR, data: { message: String(e) }, }, target ); return; } const notifications = Object.create(null); const byOrigin = Object.create(null); // Safe to flat-key by id alone: the platform derives id from a hash of // (origin, tag-or-uuid), so cross-origin collisions are negligible at // any realistic per-user count. Same convention as core notifications for (const origin of Object.keys(raw)) { const originIds = []; for (const id of Object.keys(raw[origin])) { notifications[id] = normalizeEntry(raw[origin][id], origin); originIds.push(id); } byOrigin[origin] = originIds; } this._sendToTabs( { type: at.WEB_NOTIFICATIONS_UPDATED, data: { lastUpdated: Date.now(), notifications, byOrigin, }, }, target ); } onAction(action) { switch (action.type) { case at.INIT: this._snapshot(); break; case at.NEW_TAB_LOAD: case at.WEB_NOTIFICATIONS_REQUEST: this._snapshot(action.meta?.fromTarget); break; } } }