import { authedGet, authedPost, type DeliberiContext } from "#lib/api/deliberi.ts"; import type { BobbinContext, QueryValue, XrpcRequestInit } from "#lib/api/client.ts"; import { INVALID_HANDLE, profileIdentifier, resolveMiniDoc } from "#lib/api/identity.ts"; import { getReposByRepoDids, getTickets } from "#lib/api/records.ts"; import { TICKET_COLLECTION } from "#lib/api/ticket.ts"; import { didOf, nsidOf } from "#lib/api/syntax.ts"; import { collectionFromUri, didFromUri, rkeyFromUri } from "#lib/api/uri.ts"; import type { NotificationActor, NotificationRepo, NotificationSubject, NotificationSummary, NotificationType } from "#lib/components/notifications/types.ts"; /** mirrors org.tangled.temp.notification.getPreferences#preferences */ export interface NotificationPreferences { repoStarred: boolean; issueCreated: boolean; issueCommented: boolean; issueClosed: boolean; pullCreated: boolean; pullCommented: boolean; pullMerged: boolean; followed: boolean; userMentioned: boolean; emailNotifications: boolean; } const GET_PREFERENCES = nsidOf("org.tangled.temp.notification.getPreferences"); const UPDATE_PREFERENCES = nsidOf("org.tangled.temp.notification.updatePreferences"); export const getNotificationPreferences = ( ctx: DeliberiContext, init?: XrpcRequestInit ): Promise => authedGet(ctx, GET_PREFERENCES, undefined, init); /** 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 = nsidOf("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 = nsidOf("org.tangled.temp.notification.updateSeen"); const MARK_ALL_READ = nsidOf("org.tangled.temp.notification.markAllRead"); const MARK_ENTITY_READ = nsidOf("org.tangled.temp.notification.markEntityRead"); const GET_UNREAD = nsidOf("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 }); const isTicketUri = (uri: string) => collectionFromUri(uri) === TICKET_COLLECTION; /** 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 ticketUris = [ ...new Set(items.flatMap((n) => [n.issueAt, n.pullAt]).filter((u): u is string => !!u)) ].filter(isTicketUri); const [actorDocs, repoList, ticketList] = await Promise.all([ Promise.all(actorDids.map((did) => resolveMiniDoc(ctx, did).catch(() => null))), repoDids.length > 0 ? getReposByRepoDids(ctx, repoDids).catch(() => ({ items: [] })) : Promise.resolve({ items: [] }), ticketUris.length > 0 ? getTickets(ctx, ticketUris).catch(() => ({ items: [] })) : Promise.resolve({ items: [] }) ]); const ownerDids = [...new Set(repoList.items.map((r) => r.value.declaration.owner))]; 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, profileIdentifier(doc.handle, doc.did)); } 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 = didFromUri(record.uri); const ownerDid = record.value.declaration.owner; const ownerHandle = ownerMap.get(ownerDid) ?? ownerDid; repoMap.set(repoDid, { ownerHandle, name: record.value.declaration.name }); } for (const repoDid of repoDids) { if (!repoMap.has(repoDid)) { repoMap.set(repoDid, { ownerHandle: repoDid, name: repoDid }); } } const titleMap = new Map(); for (const record of ticketList.items) { titleMap.set(record.uri, record.value.title.text); } return items.map((item): NotificationSummary => { const actorDid = didOf(item.actorDid); const actor = actorMap.get(item.actorDid) ?? { did: actorDid, handle: INVALID_HANDLE }; 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 }; }); };