feat(notification): add persistent notification
This commit is contained in:
@@ -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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user