Files
Oxicloud/frontend/src/lib/composables/useNotifications.svelte.ts
T

372 lines
13 KiB
TypeScript

/**
* Global bell store — persistent notifications (Slice E).
*
* Owns the reactive state for `NotificationBell`. Module-scoped so
* one instance drives every consumer in the SPA (badge in AppShell,
* slide-out panel, admin dashboard hooks, …). Same lifetime as
* `messageBus`: survives every intra-SPA navigation, dies only on
* full reload / tab close.
*
* Message-bus contract: the FE subscribes to `user:{me}:notifications`
* (auto-subscribed server-side on WS session open — no `rt.subscribe`
* frame needed from the client) and refetches on every push. The DB
* is truth; the bus event is a cache-invalidation hint.
*
* # Delta catch-up + dedup
*
* Two paths can deliver the SAME row and must not double-count it:
*
* 1. **WS live push** — `notification_received` event → calls
* `refreshDelta(#lastReceivedAt)` which fetches
* `?after=<lastReceivedAt>&limit=100`, merges the result into the
* reactive list.
* 2. **Reconnect catch-up** — after a grace-close (tab idle > 60 s)
* or a network drop, the WS reopens and `onReconnect` fires the
* same `refreshDelta(#lastReceivedAt)`. This backfills rows that
* landed while the socket was closed.
*
* The race: a NEW notification created after the reconnect but
* before the delta fetch returns lands via BOTH paths — WS push
* (delta fetch A) and reconnect (delta fetch B). Dedup lives in
* `mergeById`: incoming rows keyed on `id` displace any existing
* entry with the same id, so the row appears exactly once. Server
* `read_at` always wins over local because incoming replaces.
*
* `#lastReceivedAt` is the newest `created_at` we've observed. It
* feeds every delta fetch. Initial `refresh()` seeds it from the
* newest returned row; subsequent merges update it to the newest of
* the incoming set.
*/
import { messageBus } from '$lib/message-bus/client.svelte';
import { session } from '$lib/stores/session.svelte';
import { serverConfig } from '$lib/stores/serverConfig.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { t } from '$lib/i18n/index.svelte';
import {
deleteNotification as apiDelete,
getUnreadCount,
listNotifications,
markAllNotificationsRead,
markNotificationRead
} from '$lib/api/endpoints/notifications';
import type { Notification, SharegrantedPayload } from '$lib/api/types';
import { NOTIFICATION_KIND } from '$lib/api/types';
import log from 'loglevel';
const bellLog = log.getLogger('oxi:notifications');
/**
* Plain-string summary of a persistent notification — used for the
* transient toast preview (fade-to-bell UX affordance) and for
* accessible labels where a component can't render rich content.
*
* Rich Svelte rendering lives in `NotificationRow.svelte`. This is
* the string-only equivalent for toasts and aria-labels.
*/
export function summaryFor(row: Notification): string {
switch (row.kind) {
case NOTIFICATION_KIND.SHARE_GRANTED: {
const p = row.payload as unknown as SharegrantedPayload;
const resource = p.resource_name ?? p.resource_type ?? 'a resource';
return t(
'notifications.persistent.share_granted',
{ role: p.role ?? 'access', resource },
`Someone shared "${resource}" with you (${p.role ?? 'access'}).`
);
}
case NOTIFICATION_KIND.NEW_LOGIN_FROM_NEW_DEVICE:
return t(
'notifications.persistent.new_device_login',
'A new device signed into your account.'
);
case NOTIFICATION_KIND.JOB_COMPLETED_FOR_YOU: {
const name = String((row.payload as { name?: unknown }).name ?? 'a job');
return t('notifications.persistent.job_completed', { name }, `Job "${name}" finished.`);
}
case NOTIFICATION_KIND.STORAGE_QUOTA_THRESHOLD:
return t(
'notifications.persistent.quota_threshold',
'You are approaching your storage quota.'
);
default:
return t(
'notifications.persistent.generic',
{ kind: row.kind },
`Notification (${row.kind}).`
);
}
}
/**
* Merge `incoming` rows into `existing`, deduplicating on `id`.
* Where an id appears in both, the incoming (fresh-from-server)
* copy wins — so a `read_at` flip visible in `incoming` correctly
* overrides a stale local unread state. Result stays sorted
* newest-first by `created_at`.
*
* Exported for the unit tests to exercise the race semantics
* without spinning up a full store.
*/
export function mergeById(existing: Notification[], incoming: Notification[]): Notification[] {
if (incoming.length === 0) return existing;
// Local lookup set — pure function, no reactive state involved,
// so `SvelteSet` would add allocations without buying anything.
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const incomingIds = new Set(incoming.map((n) => n.id));
const kept = existing.filter((n) => !incomingIds.has(n.id));
// String compare of ISO-8601 UTC timestamps sorts identically
// to Date compare — cheaper, no allocation per row.
return [...incoming, ...kept].sort((a, b) => b.created_at.localeCompare(a.created_at));
}
class NotificationsStore {
#items = $state<Notification[]>([]);
#unread = $state<number>(0);
#loading = $state<boolean>(false);
#error = $state<string | null>(null);
/** Newest `created_at` we've observed, ISO 8601. Feeds the
* `?after=…` cursor on delta fetches. `null` until the first
* successful `refresh()` seeds it. */
#lastReceivedAt: string | null = null;
get items(): Notification[] {
return this.#items;
}
get unread(): number {
return this.#unread;
}
get loading(): boolean {
return this.#loading;
}
get error(): string | null {
return this.#error;
}
/**
* Full refresh — replaces the local list with the newest page
* from the server. Used on initial mount + as fallback when a
* delta fetch fails or a mutation reconciliation runs.
*/
async refresh(): Promise<void> {
this.#loading = true;
try {
const res = await listNotifications({ limit: 50 });
this.#items = res.items;
this.#unread = res.unread_count;
this.#lastReceivedAt = res.items[0]?.created_at ?? this.#lastReceivedAt;
this.#error = null;
} catch (e) {
this.#error = e instanceof Error ? e.message : String(e);
bellLog.warn('notifications refresh failed', e);
} finally {
this.#loading = false;
}
}
/**
* Delta fetch — pulls only rows strictly newer than
* `#lastReceivedAt` (or does nothing if we've never fetched yet;
* the caller should fall back to `refresh()` in that case).
* Merges via `mergeById` so a concurrent WS push and reconnect
* catch-up can't double-count a row that landed twice.
*
* Fresh rows — rows the local set didn't have before the merge —
* each fire a transient toast via `ui.notify(..., record: false)`
* so the user gets a peripheral awareness cue that fades to the
* bell (which keeps the row in its persistent history). The
* bell's ring animation plays via `ui.ringBell()` so a single
* bump signals "something new is in there".
*
* Silent no-op when the server returns 0 rows — we're already in
* sync. Updates `#lastReceivedAt` to the newest of the merged set.
*/
async refreshDelta(): Promise<void> {
if (this.#lastReceivedAt === null) {
// Never fetched — fall back to a full refresh so the
// caller doesn't need to distinguish the two cases.
return this.refresh();
}
try {
// `limit: 100` sized to cover realistic bell traffic per
// hour without paginating; a rare heavy sender who blows
// past 100 in one gap still gets 100 newest and the DB
// row count (unread badge) stays authoritative.
const res = await listNotifications({
after: this.#lastReceivedAt,
limit: 100
});
if (res.items.length > 0) {
// Snapshot the pre-merge id set so we can identify
// which rows are genuinely fresh vs already-known
// (an already-known row can come back on a delta
// fetch if its read_at flipped on another device).
const before = new Set(this.#items.map((n) => n.id));
this.#items = mergeById(this.#items, res.items);
// Newest of merged set — take the first item's
// created_at since the result is sorted DESC.
this.#lastReceivedAt = res.items[0].created_at;
// Toast preview for every fresh row. Pass
// `record: false` so it doesn't add a phantom
// transient entry to `ui.notifications` that would
// duplicate the persistent row already in
// `notifications.items` (the bell dropdown shows
// them side by side). `ui.ringBell()` bumps the
// bell-ring animation once for the batch.
const fresh = res.items.filter((r) => !before.has(r.id));
if (fresh.length > 0) {
for (const row of fresh) {
ui.notify(summaryFor(row), 'info', 4000, false);
}
ui.ringBell();
}
}
// unread_count is the authoritative live server count —
// always update it even when the delta was empty (a row
// could have been mark-read'd on another device).
this.#unread = res.unread_count;
this.#error = null;
} catch (e) {
this.#error = e instanceof Error ? e.message : String(e);
bellLog.warn('notifications delta failed', e);
}
}
/** Badge-only fast path — avoids fetching payloads. */
async refreshBadge(): Promise<void> {
try {
this.#unread = await getUnreadCount();
} catch (e) {
bellLog.warn('badge refresh failed', e);
}
}
async markRead(id: string): Promise<void> {
// Optimistic update — flip locally, then confirm on the wire.
// Same pattern the folder-view uses on rename: reactive-first,
// server-eventually. A wire failure re-fetches from truth.
const row = this.#items.find((n) => n.id === id);
if (row && row.read_at === null) {
row.read_at = new Date().toISOString();
this.#unread = Math.max(0, this.#unread - 1);
}
try {
await markNotificationRead(id);
} catch (e) {
bellLog.warn('markRead failed; reconciling', e);
await this.refresh();
}
}
async markAllRead(): Promise<void> {
const now = new Date().toISOString();
for (const row of this.#items) {
if (row.read_at === null) row.read_at = now;
}
this.#unread = 0;
try {
await markAllNotificationsRead();
} catch (e) {
bellLog.warn('markAllRead failed; reconciling', e);
await this.refresh();
}
}
async delete(id: string): Promise<void> {
const idx = this.#items.findIndex((n) => n.id === id);
if (idx >= 0) {
const [removed] = this.#items.splice(idx, 1);
if (removed && removed.read_at === null) {
this.#unread = Math.max(0, this.#unread - 1);
}
}
try {
await apiDelete(id);
} catch (e) {
bellLog.warn('delete failed; reconciling', e);
await this.refresh();
}
}
/** Reset — called on logout so a switch-user doesn't inherit the
* previous session's rows. */
reset(): void {
this.#items = [];
this.#unread = 0;
this.#lastReceivedAt = null;
this.#error = null;
}
}
/** Module-scoped singleton — one bell state per SPA lifetime. */
export const notifications = new NotificationsStore();
/**
* Wire the bell into a component's lifecycle. Fires an initial full
* fetch on mount, subscribes to `user:{me}:notifications` for live
* pushes, delta-fetches on reconnect (backfills rows missed during
* grace-close / network gap).
*
* Call once from the app root (`AppShell`) — this store is global.
* Additional callers do NOT need to re-mount; they can just read
* `notifications.items` / `notifications.unread`.
*/
export function useNotifications(): void {
$effect(() => {
const userId = session.user?.id;
if (!userId) return; // not logged in — nothing to fetch
// Initial hydrate from DB truth. Runs whether or not the bus
// is enabled — the bell has to work in "polling only" mode
// when OXICLOUD_MESSAGEBUS_ENABLE=false too.
void notifications.refresh();
});
$effect(() => {
if (!serverConfig.features.message_bus) return;
const userId = session.user?.id;
if (!userId) return;
// The topic is auto-subscribed server-side on WS session open
// (same pattern as `:authz`); this call refcounts up to the
// existing sub, doesn't fire a second `rt.subscribe` frame.
const release = messageBus.subscribe(
`user:${userId}:notifications`,
(params) => {
if (params.event === 'notification_received') {
// Bus event carries only the poke. Delta-fetch
// from `#lastReceivedAt` — cheap when the store
// is caught up, brings the new row with its full
// payload from truth. Dedup via `mergeById`
// handles the race with an in-flight reconnect
// catch-up returning the same row.
void notifications.refreshDelta();
}
},
() => {
// Server-evicted (session flipped) — clear local so
// the badge stops showing stale count.
notifications.reset();
}
);
const releaseReconnect = messageBus.onReconnect(() => {
// Tab was hidden > 60 s, or network dropped. WS just
// reopened — any bus events published during the gap
// are lost. Backfill via the `?after=<lastReceivedAt>`
// cursor. Server's `unread_count` in the response is
// authoritative — a mark-read on another device while
// we were dark shows up here.
//
// Race with a live rt.event that lands milliseconds
// later: `mergeById` deduplicates on `id`, so the
// same row from both paths appears exactly once.
void notifications.refreshDelta();
});
return () => {
release();
releaseReconnect();
};
});
}