import { authedGet, authedPost, type DeliberiContext } from "./deliberi"; 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 */ 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 = "org.tangled.temp.notification.getPreferences"; const UPDATE_PREFERENCES = "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 = "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 }; }); };