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<NotificationPreferences> =>
authedGet<NotificationPreferences>(ctx, GET_PREFERENCES, undefined, init);
/** only the provided fields are updated server-side. */
export const updateNotificationPreferences = (
ctx: DeliberiContext,
patch: Partial<NotificationPreferences>,
init?: XrpcRequestInit
): Promise<void> => 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<NotificationListOutput> =>
authedGet<NotificationListOutput>(
ctx,
LIST_NOTIFICATIONS,
params as Record<string, QueryValue>,
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<void> => authedPost(ctx, UPDATE_SEEN, input).then(() => undefined);
export const markAllNotificationsRead = (ctx: DeliberiContext): Promise<void> =>
authedPost(ctx, MARK_ALL_READ, {}).then(() => undefined);
export const markEntityNotificationsRead = (
ctx: DeliberiContext,
entityAt: string
): Promise<void> => 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<NotificationSummary[]> => {
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<string, string>();
for (const doc of ownerDocs) {
if (doc) ownerMap.set(doc.did, profileIdentifier(doc.handle, doc.did));
}
const actorMap = new Map<string, NotificationActor>();
for (const doc of actorDocs) {
if (doc) actorMap.set(doc.did, { did: doc.did, handle: doc.handle });
}
const repoMap = new Map<string, NotificationRepo>();
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<string, string>();
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
};
});
};