feat(notification): add persistent notification

This commit is contained in:
Edouard Vanbelle
2026-09-11 22:08:35 +02:00
parent a6138aa4d9
commit 617ae4b424
32 changed files with 1911 additions and 28 deletions
@@ -0,0 +1,76 @@
/**
* Persistent notifications (bell) — REST client.
*
* Backs `useNotifications` (composable) and `NotificationBell`
* (component). The bell reads from these; the message bus is a
* cache-invalidation hint that triggers a refetch, not a data path.
* See `docs/plan/message-bus.md § Slice E` for the pattern.
*/
import { apiJson } from '$lib/api/client';
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import type {
MarkAllReadResponse,
Notification,
NotificationListResponse,
UnreadCountResponse
} from '$lib/api/types';
/** List newest-first. Optional `unread` filter, `before` cursor, `limit` cap. */
export async function listNotifications(opts?: {
unread?: boolean;
before?: string;
limit?: number;
}): Promise<NotificationListResponse> {
const q = new URLSearchParams();
if (opts?.unread) q.set('unread', 'true');
if (opts?.before) q.set('before', opts.before);
if (opts?.limit !== undefined) q.set('limit', String(opts.limit));
const suffix = q.toString();
return apiJson<NotificationListResponse>(`/api/notifications${suffix ? `?${suffix}` : ''}`);
}
/** Badge-only fast path — no payloads fetched. */
export async function getUnreadCount(): Promise<number> {
const res = await apiJson<UnreadCountResponse>('/api/notifications/unread');
return res.unread_count;
}
/**
* Mark one notification as read. Always resolves — the server responds
* 204 regardless of whether the row existed or belonged to the caller
* (anti-enumeration). Duplicated calls are safe.
*/
export async function markNotificationRead(id: string): Promise<void> {
const res = await apiFetch(`/api/notifications/${encodeURIComponent(id)}/read`, {
method: 'POST',
headers: getCsrfHeaders()
});
if (!res.ok && res.status !== 204) {
throw new Error(`markNotificationRead failed: HTTP ${res.status}`);
}
}
/** Bulk mark-all-read. Returns the number of rows the server flipped. */
export async function markAllNotificationsRead(): Promise<number> {
const res = await apiJson<MarkAllReadResponse>('/api/notifications/read-all', {
method: 'POST',
headers: getCsrfHeaders()
});
return res.marked;
}
/** Hard-delete one row. Same anti-enum shape as mark-read. */
export async function deleteNotification(id: string): Promise<void> {
const res = await apiFetch(`/api/notifications/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: getCsrfHeaders()
});
if (!res.ok && res.status !== 204) {
throw new Error(`deleteNotification failed: HTTP ${res.status}`);
}
}
// Convenience: expose the row shape for consumers that don't want
// to import from `$lib/api/types` too.
export type { Notification };
+37
View File
@@ -934,3 +934,40 @@ export interface ServerConfig {
features: ServerFeatures;
server_status: ServerStatus;
}
// ─── Notifications (Slice E) ─────────────────────────────────────
// Row shape mirrors `application/dtos` output of the Rust backend.
// `payload` stays a raw JSON object (`Record<string, unknown>`) —
// per-kind decoding is a UI concern (kind-specific components read
// what they need from the blob). Adding a new kind server-side does
// NOT churn this file; the FE renders a generic bell row for any
// unknown kind.
export interface Notification {
id: string;
kind: string;
payload: Record<string, unknown>;
created_at: string;
/** `null` = unread. */
read_at: string | null;
}
export interface NotificationListResponse {
items: Notification[];
unread_count: number;
}
export interface UnreadCountResponse {
unread_count: number;
}
export interface MarkAllReadResponse {
marked: number;
}
/** Canonical kind slugs — mirror `domain::entities::notification::kind`. */
export const NOTIFICATION_KIND = {
SHARE_GRANTED: 'share_granted',
NEW_LOGIN_FROM_NEW_DEVICE: 'new_login_from_new_device',
JOB_COMPLETED_FOR_YOU: 'job_completed_for_you',
STORAGE_QUOTA_THRESHOLD: 'storage_quota_threshold'
} as const;
+136 -3
View File
@@ -26,6 +26,10 @@
import { session } from '$lib/stores/session.svelte';
import { theme, type Theme } from '$lib/stores/theme.svelte';
import { ui } from '$lib/stores/ui.svelte';
import {
notifications as persistentNotifications,
useNotifications
} from '$lib/composables/useNotifications.svelte';
import { errorToast } from '$lib/utils/errors';
import { formatBytes } from '$lib/utils/format';
@@ -327,6 +331,82 @@
setTimeout(() => (bellRinging = false), 900);
});
// Persistent notifications (Slice E) — server-backed rows,
// survive reload, delivered via `user:{me}:notifications` bus
// topic + refetched from `GET /api/notifications`. Fires the
// initial hydrate + subscribes to the topic. Independent of the
// transient toast bell above (`ui.notifications`) — that stays
// as-is for upload-progress / one-shot messages; this stream
// carries `share_granted` and friends.
useNotifications();
// Merged unread count for the bell badge — transient toasts plus
// persistent unread rows. Same wire and same UX affordance so a
// user sees one number and one bell for both classes.
const totalUnread = $derived(ui.unread + persistentNotifications.unread);
const totalUnreadBadge = $derived(totalUnread > 99 ? '99+' : String(totalUnread));
/** Format the server-side `created_at` for a persistent row. */
function formatPersistentTime(iso: string): string {
try {
return formatTime(new Date(iso).getTime());
} catch {
return '';
}
}
/** Human summary for a persistent notification. Kind-specific
* wording lives here so the DTO stays payload-agnostic. */
function persistentSummary(row: { kind: string; payload: Record<string, unknown> }): string {
switch (row.kind) {
case 'share_granted': {
const role = String(row.payload.role ?? 'a role');
const resType = String(row.payload.resource_type ?? 'resource');
return t(
'notifications.persistent.share_granted',
{ role, resType },
`You were granted ${role} on a ${resType}.`
);
}
case 'new_login_from_new_device':
return t(
'notifications.persistent.new_device_login',
'A new device signed into your account.'
);
case 'job_completed_for_you': {
const name = String(row.payload.name ?? row.payload.job_name ?? 'a job');
return t('notifications.persistent.job_completed', { name }, `Job "${name}" finished.`);
}
case '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}).`
);
}
}
/** Icon for a persistent row's kind. Falls back to a generic bell. */
function persistentIcon(kind: string): string {
switch (kind) {
case 'share_granted':
return 'user-plus';
case 'new_login_from_new_device':
return 'shield-alt';
case 'job_completed_for_you':
return 'check-circle';
case 'storage_quota_threshold':
return 'database';
default:
return 'bell';
}
}
function openMobileSearch() {
searchActive = true;
requestAnimationFrame(() => searchInputEl?.focus());
@@ -802,11 +882,19 @@
e.stopPropagation();
notifOpen = !notifOpen;
menuOpen = false;
if (notifOpen) ui.markNotificationsRead();
if (notifOpen) {
ui.markNotificationsRead();
// Persistent rows stay unread until the user
// explicitly clicks one — opening the panel
// doesn't mark them read (unlike the transient
// toast bell, which resets on view). Keeps the
// bell's badge accurate to "still-relevant
// server-side rows" without a bulk mark-read.
}
}}
>
<Icon name="bell" />
{#if ui.unread > 0}<span class="notif-badge">{ui.unreadBadge}</span>{/if}
{#if totalUnread > 0}<span class="notif-badge">{totalUnreadBadge}</span>{/if}
</button>
<div class="notif-panel">
<div class="notif-panel-header">
@@ -827,7 +915,7 @@
{/if}
</div>
<div class="notif-panel-body">
{#if ui.notifications.length === 0}
{#if ui.notifications.length === 0 && persistentNotifications.items.length === 0}
<div class="notif-empty">
<Icon name="bell-slash" />
<span>{t('notifications.empty', 'No notifications')}</span>
@@ -869,6 +957,43 @@
</div>
</div>
{/each}
{#if persistentNotifications.items.length > 0}
{#if ui.notifications.length > 0}
<div
class="notif-section-divider"
role="separator"
aria-orientation="horizontal"
></div>
{/if}
{#each persistentNotifications.items as row (row.id)}
<div
class="notif-item notif-item--{row.kind}"
role="button"
tabindex="0"
data-testid="appshell-notif-persistent-item"
aria-label={persistentSummary(row)}
style:font-weight={row.read_at === null ? '500' : 'normal'}
style:cursor="pointer"
onclick={() => void persistentNotifications.markRead(row.id)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
void persistentNotifications.markRead(row.id);
}
}}
>
<span class="notif-item-icon">
<Icon name={persistentIcon(row.kind)} />
</span>
<div class="notif-item-body">
<div class="notif-item-text">{persistentSummary(row)}</div>
<div class="notif-item-time">
{formatPersistentTime(row.created_at)}
</div>
</div>
</div>
{/each}
{/if}
{/if}
</div>
</div>
@@ -1295,6 +1420,14 @@
white-space: nowrap;
}
/* Divider between transient toasts and persistent (server-backed)
rows. Slice E adds a section under the toast list; the divider
is only rendered when both sections have content. */
.notif-section-divider {
border-top: 1px solid var(--color-border);
margin: 0.5rem 0;
}
/* Bell "ring" animation, replayed when bellRinging toggles on. */
.notif-bell-btn.ring :global(svg),
.notif-bell-btn.ring :global(i) {
@@ -0,0 +1,192 @@
/**
* 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 the row list whenever
* a `notification_received` event arrives. The DB is truth; the bus
* event just says "there's new data, refresh".
*/
import { messageBus } from '$lib/message-bus/client.svelte';
import { session } from '$lib/stores/session.svelte';
import { serverConfig } from '$lib/stores/serverConfig.svelte';
import {
deleteNotification as apiDelete,
getUnreadCount,
listNotifications,
markAllNotificationsRead,
markNotificationRead
} from '$lib/api/endpoints/notifications';
import type { Notification } from '$lib/api/types';
import log from 'loglevel';
const bellLog = log.getLogger('oxi:notifications');
class NotificationsStore {
#items = $state<Notification[]>([]);
#unread = $state<number>(0);
#loading = $state<boolean>(false);
#error = $state<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;
}
/**
* Fetch the newest page + refresh the badge count. Idempotent —
* safe to call on every bus push, on mount, on visibility return.
*/
async refresh(): Promise<void> {
this.#loading = true;
try {
const res = await listNotifications({ limit: 50 });
this.#items = res.items;
this.#unread = res.unread_count;
this.#error = null;
} catch (e) {
this.#error = e instanceof Error ? e.message : String(e);
bellLog.warn('notifications refresh failed', e);
} finally {
this.#loading = false;
}
}
/** 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.#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 fetch
* on mount, subscribes to `user:{me}:notifications` for live pushes,
* refetches on reconnect (bus events lost during outage window).
*
* Call once from the app root (`+layout.svelte`) — 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. Refetch the
// list — cheap, gives us the new row with its
// full payload from truth.
void notifications.refresh();
}
},
() => {
// Server-evicted (session flipped) — clear local so
// the badge stops showing stale count.
notifications.reset();
}
);
const releaseReconnect = messageBus.onReconnect(() => {
// A push we missed during the outage window is only
// recoverable by rereading the DB.
void notifications.refresh();
});
return () => {
release();
releaseReconnect();
};
});
}
@@ -0,0 +1,8 @@
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface NotificationReceivedData {
created_at: string;
kind: string;
notification_id: string;
}
export type { NotificationReceivedData as default };
@@ -7,6 +7,7 @@ enum RtEventKind {
FOLDER_RENAMED = 'folder_renamed',
FOLDER_MOVED = 'folder_moved',
FOLDER_DELETED = 'folder_deleted',
NOTIFICATION_RECEIVED = 'notification_received',
JOB_RUN_STARTED = 'job_run_started',
JOB_RUN_PROGRESS = 'job_run_progress',
JOB_RUN_ENDED = 'job_run_ended'
@@ -6,6 +6,7 @@ import type FolderCreatedData from './FolderCreatedData';
import type FolderRenamedData from './FolderRenamedData';
import type FolderMovedData from './FolderMovedData';
import type FolderDeletedData from './FolderDeletedData';
import type NotificationReceivedData from './NotificationReceivedData';
import type JobRunStartedData from './JobRunStartedData';
import type JobRunProgressData from './JobRunProgressData';
import type JobRunEndedData from './JobRunEndedData';
@@ -22,6 +23,7 @@ interface RtEventParams {
| FolderRenamedData
| FolderMovedData
| FolderDeletedData
| NotificationReceivedData
| JobRunStartedData
| JobRunProgressData
| JobRunEndedData;