From bd516d8868ce448d145304828235ba8e74171c4e Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Thu, 20 Aug 2026 15:48:52 +0300 Subject: [PATCH] web: load notifications from deliberi instead of mock fixtures Signed-off-by: Anirudh Oppiliappan --- deliberi/readme.md | 2 + web/src/lib/api/notifications.ts | 184 +++++++++++++++++- .../NotificationBell.stories.svelte | 1 + .../notifications/NotificationBell.svelte | 78 ++++++-- .../NotificationItem.stories.svelte | 2 +- .../notifications/NotificationItem.svelte | 6 +- .../notifications/NotificationList.svelte | 4 +- web/src/lib/components/notifications/mock.ts | 149 +++++++------- .../components/notifications/notification.ts | 2 +- web/src/lib/components/notifications/types.ts | 6 +- web/src/lib/oauth-client-metadata.json | 15 +- web/src/routes/notifications/+page.svelte | 61 +++++- web/src/routes/notifications/+page.ts | 18 +- 13 files changed, 397 insertions(+), 131 deletions(-) create mode 100644 deliberi/readme.md diff --git a/deliberi/readme.md b/deliberi/readme.md new file mode 100644 index 00000000..311913a0 --- /dev/null +++ b/deliberi/readme.md @@ -0,0 +1,2 @@ +deliberi is the off-protocol data server. It handles notifications fan-out and +their preferences, and email delivery. diff --git a/web/src/lib/api/notifications.ts b/web/src/lib/api/notifications.ts index 66e3880e..fb2ff53d 100644 --- a/web/src/lib/api/notifications.ts +++ b/web/src/lib/api/notifications.ts @@ -1,7 +1,19 @@ import { authedGet, authedPost, type DeliberiContext } from "./deliberi"; -import type { XrpcRequestInit } from "./client"; +import type { BobbinContext, QueryValue, XrpcRequestInit } from "./client"; +import { resolveMiniDoc } from "./identity"; +import { getReposByRepoDids } from "./records"; +import { getIssues } from "./issue"; +import { getPulls } from "./records"; +import { didFromUri, rkeyFromUri } from "./uri"; +import type { + NotificationActor, + NotificationRepo, + NotificationSubject, + NotificationSummary, + NotificationType +} from "$lib/components/notifications/types"; -// mirrors org.tangled.temp.notification.getPreferences#preferences +/** mirrors org.tangled.temp.notification.getPreferences#preferences */ export interface NotificationPreferences { repoStarred: boolean; issueCreated: boolean; @@ -24,9 +36,175 @@ export const getNotificationPreferences = ( ): Promise => authedGet(ctx, GET_PREFERENCES, undefined, init); -// only the provided fields are updated server-side. +/** only the provided fields are updated server-side. */ export const updateNotificationPreferences = ( ctx: DeliberiContext, patch: Partial, init?: XrpcRequestInit ): Promise => authedPost(ctx, UPDATE_PREFERENCES, patch, init).then(() => undefined); + +/** raw notification row returned by deliberi */ +export interface NotificationItem { + uri: string; + type: string; + category: string; + actorDid: string; + read: boolean; + createdAt: string; + repoDid?: string; + issueAt?: string; + pullAt?: string; +} + +export interface NotificationListOutput { + notifications: NotificationItem[]; + socialUnreadCount: number; + workUnreadCount: number; +} + +const LIST_NOTIFICATIONS = "org.tangled.temp.notification.listNotifications"; + +export const listNotifications = ( + ctx: DeliberiContext, + params?: { read?: string; category?: string; limit?: number }, + init?: XrpcRequestInit +): Promise => + authedGet( + ctx, + LIST_NOTIFICATIONS, + params as Record, + init + ); + +const UPDATE_SEEN = "org.tangled.temp.notification.updateSeen"; +const MARK_ALL_READ = "org.tangled.temp.notification.markAllRead"; +const MARK_ENTITY_READ = "org.tangled.temp.notification.markEntityRead"; +const GET_UNREAD = "org.tangled.temp.notification.getUnreadCount"; + +export const updateNotificationSeen = ( + ctx: DeliberiContext, + input: { uri: string; read: boolean } +): Promise => authedPost(ctx, UPDATE_SEEN, input).then(() => undefined); + +export const markAllNotificationsRead = (ctx: DeliberiContext): Promise => + authedPost(ctx, MARK_ALL_READ, {}).then(() => undefined); + +export const markEntityNotificationsRead = ( + ctx: DeliberiContext, + entityAt: string +): Promise => authedPost(ctx, MARK_ENTITY_READ, { entityAt }).then(() => undefined); + + +export const getUnreadCount = ( + ctx: DeliberiContext +): Promise<{ count: number }> => + authedGet(ctx, GET_UNREAD); + +const subjectFromUri = ( + uri: string, + kind: "issue" | "pull", + title?: string +): NotificationSubject => ({ + kind, + uri, + number: rkeyFromUri(uri), + title: title ?? rkeyFromUri(uri) +}); + +/** resolve rows best-effort: missing actor, repo, or entity falls back to the raw value */ +export const hydrateNotifications = async ( + ctx: BobbinContext, + items: NotificationItem[] +): Promise => { + if (items.length === 0) return []; + + const actorDids = [...new Set(items.map((n) => n.actorDid))]; + + const repoDids = [...new Set(items.map((n) => n.repoDid).filter((d): d is string => !!d))]; + + const issueUris = [ + ...new Set(items.map((n) => n.issueAt).filter((u): u is string => !!u)) + ]; + const pullUris = [ + ...new Set(items.map((n) => n.pullAt).filter((u): u is string => !!u)) + ]; + + const [actorDocs, repoList, issueList, pullList] = await Promise.all([ + Promise.all( + actorDids.map((did) => + resolveMiniDoc(ctx, did).catch(() => null) + ) + ), + repoDids.length > 0 + ? getReposByRepoDids(ctx, repoDids).catch(() => ({ items: [] })) + : Promise.resolve({ items: [] }), + issueUris.length > 0 + ? getIssues(ctx, issueUris).catch(() => ({ items: [] })) + : Promise.resolve({ items: [] }), + pullUris.length > 0 + ? getPulls(ctx, pullUris).catch(() => ({ items: [] })) + : Promise.resolve({ items: [] }) + ]); + const ownerDids = [...new Set(repoList.items.map((r) => didFromUri(r.uri)))]; + const ownerDocs = await Promise.all( + ownerDids.map((did) => resolveMiniDoc(ctx, did).catch(() => null)) + ); + const ownerMap = new Map(); + for (const doc of ownerDocs) { + if (doc) ownerMap.set(doc.did, doc.handle); + } + + const actorMap = new Map(); + for (const doc of actorDocs) { + if (doc) actorMap.set(doc.did, { did: doc.did, handle: doc.handle }); + } + + const repoMap = new Map(); + for (const record of repoList.items) { + const repoDid = record.value.repoDid; + if (!repoDid) continue; + const ownerDid = didFromUri(record.uri); + const ownerHandle = ownerMap.get(ownerDid) ?? ownerDid; + repoMap.set(repoDid, { + ownerHandle, + name: record.value.name ?? repoDid + }); + } + for (const repoDid of repoDids) { + if (!repoMap.has(repoDid)) { + repoMap.set(repoDid, { ownerHandle: repoDid, name: repoDid }); + } + } + + const titleMap = new Map(); + for (const record of issueList.items) { + titleMap.set(record.uri, record.value.title); + } + for (const record of pullList.items) { + titleMap.set(record.uri, record.value.title); + } + + return items.map((item): NotificationSummary => { + const actor = actorMap.get(item.actorDid) ?? { + did: item.actorDid, + handle: item.actorDid + }; + const repo = item.repoDid ? repoMap.get(item.repoDid) : undefined; + let subject: NotificationSubject | undefined; + if (item.issueAt) { + subject = subjectFromUri(item.issueAt, "issue", titleMap.get(item.issueAt)); + } else if (item.pullAt) { + subject = subjectFromUri(item.pullAt, "pull", titleMap.get(item.pullAt)); + } + + return { + uri: item.uri, + type: item.type as NotificationType, + read: item.read, + createdAt: item.createdAt, + actor, + repo, + subject + }; + }); +}; diff --git a/web/src/lib/components/notifications/NotificationBell.stories.svelte b/web/src/lib/components/notifications/NotificationBell.stories.svelte index 149a3215..b1a0229f 100644 --- a/web/src/lib/components/notifications/NotificationBell.stories.svelte +++ b/web/src/lib/components/notifications/NotificationBell.stories.svelte @@ -9,6 +9,7 @@ const many = Array.from({ length: 120 }, (_, index) => ({ ...notifications[index % notifications.length], id: index + 1, + uri: `at://mock/notif/${index + 1}`, read: false })); diff --git a/web/src/lib/components/notifications/NotificationBell.svelte b/web/src/lib/components/notifications/NotificationBell.svelte index d63c3df5..c4d7a359 100644 --- a/web/src/lib/components/notifications/NotificationBell.svelte +++ b/web/src/lib/components/notifications/NotificationBell.svelte @@ -1,22 +1,34 @@ diff --git a/web/src/routes/notifications/+page.ts b/web/src/routes/notifications/+page.ts index 91dd8b63..f7be72f9 100644 --- a/web/src/routes/notifications/+page.ts +++ b/web/src/routes/notifications/+page.ts @@ -1,14 +1,16 @@ -import { notifications } from "$lib/components/notifications/mock"; -import type { ReadFilter } from "$lib/components/notifications/types"; +import type { CategoryFilter, ReadFilter } from "$lib/components/notifications/types"; import type { PageLoad } from "./$types"; -// org.tangled.temp.notification.listNotifications already serves this page's -// data, but it returns at-uris where the rows need a repo name, an issue number -// and a title. until something hydrates those, the fixtures stand in. -export const load: PageLoad = (event) => { +export const load: PageLoad = async (event) => { + const parent = await event.parent(); const read: ReadFilter = event.url.searchParams.get("read") === "unread" ? "unread" : "inbox"; const requested = event.url.searchParams.get("category"); - const category = requested === "work" || requested === "social" ? requested : "all"; + const category: CategoryFilter = requested === "work" || requested === "social" ? requested : "all"; - return { notifications, readFilter: read, categoryFilter: category }; + return { + readFilter: read, + categoryFilter: category, + deliberiUrl: parent.publicConfig.deliberiUrl, + bobbinUrl: parent.publicConfig.bobbinUrl + }; }; -- 2.51.2