Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302import type { Did, ResourceUri } from "@atcute/lexicons/syntax";import type { OAuthUserAgent } from "@atcute/oauth-browser-client";import { ClientResponseError, type BobbinContext } from "#lib/api/client.ts";import { jsonGet } from "#lib/api/_request.ts";import { httpStatusFor } from "#lib/api/load.ts";import { nsidOf, recordKeyOf } from "#lib/api/syntax.ts";import { rkeyFromUri } from "#lib/api/uri.ts";import { createRecord, deleteRecord, putRecord, type WrittenRecord } from "#lib/api/write.ts";import type { VouchRecord } from "#lib/api/records.ts";import type * as OrgTangledFeedGetStar from "#lib/api/lexicons/types/org/tangled/feed/getStar.ts";import type * as OrgTangledGraphFollow from "#lib/api/lexicons/types/org/tangled/graph/follow.ts";import type * as OrgTangledGraphGetFollow from "#lib/api/lexicons/types/org/tangled/graph/getFollow.ts";import type * as OrgTangledGraphListNetworkVouches from "#lib/api/lexicons/types/org/tangled/graph/listNetworkVouches.ts";
export type FollowRecord = OrgTangledGraphFollow.Main;export type { VouchRecord };
const FOLLOW_COLLECTION = nsidOf("org.tangled.graph.follow");const STAR_COLLECTION = nsidOf("org.tangled.feed.star");const VOUCH_COLLECTION = nsidOf("org.tangled.graph.vouch");const SUBSCRIPTION_COLLECTION = nsidOf("org.tangled.feed.subscription");
export const getFollowRkey = async ( ctx: BobbinContext, actor: string, subject: string): Promise<string | null> => { try { const { uri } = await jsonGet<OrgTangledGraphGetFollow.$output>( ctx, "org.tangled.graph.getFollow", { actor: actor as Did, subject: subject as Did } satisfies OrgTangledGraphGetFollow.$params ); return rkeyFromUri(uri); } catch (cause) { if (cause instanceof ClientResponseError && httpStatusFor(cause) === 404) return null; throw cause; }};
export const getStarRkey = async ( ctx: BobbinContext, actor: string, subject: string): Promise<string | null> => { try { const { uri } = await jsonGet<OrgTangledFeedGetStar.$output>( ctx, "org.tangled.feed.getStar", { actor: actor as Did, subject } satisfies OrgTangledFeedGetStar.$params ); return rkeyFromUri(uri); } catch (cause) { if (cause instanceof ClientResponseError && httpStatusFor(cause) === 404) return null; throw cause; }};
export const createFollow = async ( agent: OAuthUserAgent, subject: string): Promise<WrittenRecord & { rkey: string }> => { // keyed by the subject did const written = await putRecord(agent, FOLLOW_COLLECTION, recordKeyOf(subject), { $type: "org.tangled.graph.follow", createdAt: new Date().toISOString() }); return { ...written, rkey: rkeyFromUri(written.uri) };};
export const deleteFollow = async ( agent: OAuthUserAgent, rkey: string): Promise<{ uri: ResourceUri }> => { const uri = `at://${agent.sub}/${FOLLOW_COLLECTION}/${rkey}` as ResourceUri; await deleteRecord(agent, FOLLOW_COLLECTION, recordKeyOf(rkey)); return { uri };};
export type StarSubject = | { kind: "repo"; did: string; ownerHandle: string; name: string } | { kind: "string"; uri: string; cid?: string };
export const createStar = async ( agent: OAuthUserAgent, subject: StarSubject): Promise<WrittenRecord & { rkey: string }> => { let record; if (subject.kind === "repo") { record = { $type: "org.tangled.feed.star#repo" as const, identity: subject.did as Did }; } else { if (!subject.cid) throw new Error("Cannot star a record without its cid"); record = { $type: "org.tangled.feed.star#record" as const, record: { uri: subject.uri as ResourceUri, cid: subject.cid } }; } const written = await createRecord(agent, STAR_COLLECTION, { $type: "org.tangled.feed.star", subject: record, createdAt: new Date().toISOString() }); return { ...written, rkey: rkeyFromUri(written.uri) };};
export const deleteStar = async ( agent: OAuthUserAgent, rkey: string): Promise<{ uri: ResourceUri }> => { const uri = `at://${agent.sub}/${STAR_COLLECTION}/${rkey}` as ResourceUri; await deleteRecord(agent, STAR_COLLECTION, recordKeyOf(rkey)); return { uri };};
export const createSubscription = async ( agent: OAuthUserAgent, repoDid: string): Promise<WrittenRecord & { rkey: string }> => { const written = await createRecord(agent, SUBSCRIPTION_COLLECTION, { $type: SUBSCRIPTION_COLLECTION, subject: { $type: "org.tangled.feed.subscription#repo", did: repoDid as Did }, createdAt: new Date().toISOString() }); return { ...written, rkey: rkeyFromUri(written.uri) };};
export const deleteSubscription = async ( agent: OAuthUserAgent, rkey: string): Promise<{ uri: ResourceUri }> => { const uri = `at://${agent.sub}/${SUBSCRIPTION_COLLECTION}/${rkey}` as ResourceUri; await deleteRecord(agent, SUBSCRIPTION_COLLECTION, recordKeyOf(rkey)); return { uri };};
// bobbin sends a mute back as a record too, same rkey with ignore setexport interface EntitySubscription { rkey: string; ignore: boolean;}
// no record means deliberi still notifies, so a mute is ignore: true, not a deleteconst writeEntitySubscription = async ( agent: OAuthUserAgent, entityUri: string, ignore: boolean, existingRkey?: string): Promise<string> => { const record = { $type: SUBSCRIPTION_COLLECTION, subject: { $type: "org.tangled.feed.subscription#record" as const, uri: entityUri }, ...(ignore ? { ignore } : {}), createdAt: new Date().toISOString() }; if (existingRkey) { await putRecord(agent, SUBSCRIPTION_COLLECTION, recordKeyOf(existingRkey), record); return existingRkey; } const { uri } = await createRecord(agent, SUBSCRIPTION_COLLECTION, record); return rkeyFromUri(uri);};
export const createEntitySubscription = (agent: OAuthUserAgent, entityUri: string) => writeEntitySubscription(agent, entityUri, false);
export const getEntitySubscription = async ( ctx: BobbinContext, actor: string, subject: string): Promise<EntitySubscription | null> => { try { const { uri, ignore } = await jsonGet<{ uri: string; ignore?: boolean }>( ctx, "org.tangled.feed.subscription.getForActor", { actor: actor as Did, subject } ); return { rkey: rkeyFromUri(uri), ignore: ignore === true }; } catch (cause) { if (cause instanceof ClientResponseError && httpStatusFor(cause) === 404) return null; throw cause; }};
export const getSubscriptionRkey = async ( ctx: BobbinContext, actor: string, subject: string): Promise<string | null> => (await getEntitySubscription(ctx, actor, subject))?.rkey ?? null;
// a stale button would stack a second record, so reread the rkey firstexport const setEntitySubscribed = async ( agent: OAuthUserAgent, ctx: BobbinContext, actor: string, entityUri: string, subscribed: boolean): Promise<string> => { const existing = await getEntitySubscription(ctx, actor, entityUri); return writeEntitySubscription(agent, entityUri, !subscribed, existing?.rkey);};
export const ENTITY_SUBSCRIBED = "entity-subscribed";
// the page's subscribe button can't see a comment subscribing, so it gets this eventconst announceSubscribed = (entityUri: string, rkey: string) => { if (typeof window === "undefined") return; window.dispatchEvent(new CustomEvent(ENTITY_SUBSCRIBED, { detail: { entityUri, rkey } }));};
export const ensureEntitySubscription = async ( agent: OAuthUserAgent, ctx: BobbinContext, actor: string, entityUri: string): Promise<string> => { const existing = await getEntitySubscription(ctx, actor, entityUri); if (existing && !existing.ignore) return existing.rkey; const rkey = await writeEntitySubscription(agent, entityUri, false, existing?.rkey); announceSubscribed(entityUri, rkey); return rkey;};
export type VouchKind = "vouch" | "denounce";
export interface VouchInput { kind: VouchKind; reason?: string; evidences?: string[];}
export const putVouch = async ( agent: OAuthUserAgent, subject: string, input: VouchInput): Promise<WrittenRecord> => { return putRecord(agent, VOUCH_COLLECTION, recordKeyOf(subject), { $type: "org.tangled.graph.vouch", kind: input.kind, ...(input.reason ? { reason: input.reason } : {}), ...(input.evidences?.length ? { evidences: input.evidences } : {}), createdAt: new Date().toISOString() });};
export const deleteVouch = async ( agent: OAuthUserAgent, subject: string): Promise<{ uri: ResourceUri }> => { const uri = `at://${agent.sub}/${VOUCH_COLLECTION}/${subject}` as ResourceUri; await deleteRecord(agent, VOUCH_COLLECTION, recordKeyOf(subject)); return { uri };};export type NetworkVouchItem = Omit<OrgTangledGraphListNetworkVouches.ListItem, "value"> & { value: VouchRecord;};
export interface NetworkVouchesByActor { items: Record<string, NetworkVouchItem[]>; cursor?: string;}
export const listNetworkVouches = async ( ctx: BobbinContext, actors: readonly string[], options: { limit?: number; cursor?: string } = {}): Promise<NetworkVouchesByActor> => { const unique = [...new Set(actors)]; if (options.cursor && unique.length !== 1) { throw new Error("A vouch cursor requires exactly one actor"); } const groups = Object.fromEntries(unique.map((actor) => [actor, [] as NetworkVouchItem[]])); const pages = await Promise.all( Array.from({ length: Math.ceil(unique.length / 50) }, (_, index) => jsonGet<OrgTangledGraphListNetworkVouches.$output>( ctx, "org.tangled.graph.listNetworkVouches", { actors: unique.slice(index * 50, (index + 1) * 50) as Did[], limit: options.limit, cursor: options.cursor } satisfies OrgTangledGraphListNetworkVouches.$params ) ) ); for (const page of pages) { for (const item of page.items) groups[item.actor]?.push(item as NetworkVouchItem); } return { items: groups, cursor: pages[0]?.cursor };};