import 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 set
export interface EntitySubscription {
rkey: string;
ignore: boolean;
}
// no record means deliberi still notifies, so a mute is ignore: true, not a delete
const 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 first
export 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 event
const 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 };
};