diff --git a/web/src/lib/components/timeline/NotificationsPreview.stories.svelte b/web/src/lib/components/timeline/NotificationsPreview.stories.svelte index 047e321a5..1c78cd61f 100644 --- a/web/src/lib/components/timeline/NotificationsPreview.stories.svelte +++ b/web/src/lib/components/timeline/NotificationsPreview.stories.svelte @@ -1,5 +1,6 @@ - - + + + ({ ...n, read: true })) + }} +/> + + + diff --git a/web/src/lib/components/timeline/NotificationsPreview.svelte b/web/src/lib/components/timeline/NotificationsPreview.svelte index 866cb352c..8976f38e7 100644 --- a/web/src/lib/components/timeline/NotificationsPreview.svelte +++ b/web/src/lib/components/timeline/NotificationsPreview.svelte @@ -4,14 +4,35 @@ import Bell from "$icon/bell"; import BellOff from "$icon/bell-off"; import Inbox from "$icon/inbox"; + import { createLoad } from "$lib/action.svelte"; + import NotificationList from "$lib/components/notifications/NotificationList.svelte"; + import type { NotificationSummary } from "$lib/components/notifications/types"; + import { getNotifications, type Notifications } from "$lib/notifications.svelte"; import PreviewCard from "./PreviewCard.svelte"; import PreviewEmpty from "./PreviewEmpty.svelte"; - // there is no notification item component yet, so this renders the two empty - // states only. items land under the header once that exists. - let { signedIn = false }: { signedIn?: boolean } = $props(); + // the appview preview serves five rows and sends the rest to the full page + const PREVIEW_LIMIT = 5; + let { + signedIn = false, + notifications: fixtures + }: { signedIn?: boolean; notifications?: NotificationSummary[] } = $props(); + + // stories render this card without the layout, so the context can be absent + const context = getNotifications() as Notifications | undefined; const path = "/notifications"; + + const preview = createLoad(async () => { + if (!signedIn) return null; + if (fixtures) { + return { notifications: fixtures, totalUnread: fixtures.filter((n) => !n.read).length }; + } + return (await context?.preview(PREVIEW_LIMIT)) ?? null; + }); + + const items = $derived(preview.data?.notifications ?? []); + const totalUnread = $derived(preview.data?.totalUnread ?? null); @@ -25,13 +46,19 @@ {/snippet} - {#if signedIn} + {#if !signedIn} + + Log in to view your inbox + + {:else if items.length > 0} + + {:else if totalUnread === 0}

All caught up!

{:else} - - Log in to view your inbox + + Open your inbox {/if}
diff --git a/web/src/lib/notifications.svelte.ts b/web/src/lib/notifications.svelte.ts index 2f04a7267..2c78b61b2 100644 --- a/web/src/lib/notifications.svelte.ts +++ b/web/src/lib/notifications.svelte.ts @@ -1,15 +1,35 @@ import { getContext } from "svelte"; +import { createBobbinClient } from "$lib/api/client"; import { createDeliberiClient } from "$lib/api/deliberi"; -import { markEntityNotificationsRead } from "$lib/api/notifications"; +import { + getUnreadCount, + hydrateNotifications, + listNotifications, + markEntityNotificationsRead, + type NotificationItem +} from "$lib/api/notifications"; +import type { NotificationSummary } from "$lib/components/notifications/types"; import type { Auth } from "$lib/auth.svelte"; export const NOTIFICATIONS_KEY = Symbol("notifications"); +export interface NotificationsPreview { + notifications: NotificationSummary[]; + totalUnread: number; +} + export interface Notifications { markEntityVisited(entityUri: string): Promise; + /** latest rows plus the account-wide unread count, null when signed out + * or deliberi is unconfigured */ + preview(limit: number): Promise; } -export const createNotifications = (auth: Auth, deliberiUrl: string | undefined): Notifications => { +export const createNotifications = ( + auth: Auth, + deliberiUrl: string | undefined, + bobbinUrl: string +): Notifications => { const markEntityVisited = async (entityUri: string): Promise => { // yield first so the call lands outside the $effect that invoked it await Promise.resolve(); @@ -19,7 +39,20 @@ export const createNotifications = (auth: Auth, deliberiUrl: string | undefined) await markEntityNotificationsRead(deliberi, entityUri).catch(() => {}); }; - return { markEntityVisited }; + const preview = async (limit: number): Promise => { + const agent = auth.agent; + if (!agent || !deliberiUrl) return null; + const deliberi = createDeliberiClient({ deliberiUrl, agent }); + const hydrate = (items: NotificationItem[]): Promise => + hydrateNotifications(createBobbinClient({ serviceUrl: bobbinUrl }), items); + const [countResult, listResult] = await Promise.all([ + getUnreadCount(deliberi), + listNotifications(deliberi, { limit }).then((r) => hydrate(r.notifications)) + ]); + return { notifications: listResult, totalUnread: countResult.count }; + }; + + return { markEntityVisited, preview }; }; export const getNotifications = () => getContext(NOTIFICATIONS_KEY); diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index b5c03bdf9..f2f6f00fe 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -19,7 +19,9 @@ const auth = untrack(() => createAuth(data.publicConfig.bobbinUrl, data.auth)); setContext(AUTH_KEY, auth); - const notifications = untrack(() => createNotifications(auth, data.publicConfig.deliberiUrl)); + const notifications = untrack(() => + createNotifications(auth, data.publicConfig.deliberiUrl, data.publicConfig.bobbinUrl) + ); setContext(NOTIFICATIONS_KEY, notifications); onMount(() => {