import { ok } from '@atcute/client';
import type { OAuthUserAgent } from '@atcute/oauth-browser-client';
import type { ResolvedActor } from '@atcute/identity-resolver';
import type { Did, Nsid, ResourceUri } from '@atcute/lexicons/syntax';
import { ShTangledString, ShTangledFeedStar } from '@atcute/tangled';
import {
appviewFallback,
listAllAppviewRecords,
getDistinctAppviewRecordCount,
type AppviewRecordView,
} from './appview';
import { getRpc } from './client';
import { STRING_COLLECTION, STAR_COLLECTION, FEED_COMMENT_COLLECTION } from './constants';
import { resolveActor, createAuthRpc } from './identity';
import { parseAtUri, type RecordCreationResult } from './records';
import { appviewRecordToHydrated } from './hydration';
export interface StringRecord {
uri: ResourceUri;
cid: string;
rkey: string;
value: ShTangledString.Main;
}
export interface StringComment {
uri: ResourceUri;
cid?: string;
rkey: string;
author: ResolvedActor;
value: {
$type: string;
body: string;
createdAt: string;
subject: { uri: string; cid: string };
replyTo?: { uri: string; cid: string };
};
}
export interface StringStarSummary {
count: number;
isStarred: boolean;
currentUserStarRkey?: string;
}
const fetchAllStringRecords = async (
actor: ResolvedActor,
collection: Nsid,
): Promise<Array<{ uri: ResourceUri; cid: string; value: unknown }>> => {
const rpc = getRpc(actor.pds);
const records: Array<{ uri: ResourceUri; cid: string; value: unknown }> = [];
let cursor: string | undefined;
do {
const page = await ok(
rpc.get('com.atproto.repo.listRecords', {
params: {
repo: actor.did,
collection,
limit: 100,
cursor,
reverse: true,
},
}),
);
records.push(...page.records);
cursor = page.cursor;
} while (cursor);
return records;
};
const listStringRecordsFromAppview = async (owner: string | ResolvedActor): Promise<StringRecord[]> => {
const actor = typeof owner === 'string' ? await resolveActor(owner) : owner;
const records = await listAllAppviewRecords<ShTangledString.Main>('sh.tangled.string.listStrings', actor.did);
return records.map((record) => ({
uri: record.uri,
cid: record.cid ?? '',
rkey: parseAtUri(record.uri).rkey,
value: record.value,
}));
};
const listStringRecordsFromPds = async (owner: string | ResolvedActor): Promise<StringRecord[]> => {
const actor = typeof owner === 'string' ? await resolveActor(owner) : owner;
const records = await fetchAllStringRecords(actor, STRING_COLLECTION);
return records.map((record) => ({
uri: record.uri,
cid: record.cid,
rkey: parseAtUri(record.uri).rkey,
value: record.value as ShTangledString.Main,
}));
};
export const listStringRecords = async (owner: string | ResolvedActor): Promise<StringRecord[]> =>
appviewFallback(
() => listStringRecordsFromAppview(owner),
() => listStringRecordsFromPds(owner),
);
export const getString = async (
ownerIdentifier: string,
rkey: string,
): Promise<{ owner: ResolvedActor; record: StringRecord }> => {
const owner = await resolveActor(ownerIdentifier);
const response = await ok(
getRpc(owner.pds).get('com.atproto.repo.getRecord', {
params: {
repo: owner.did,
collection: STRING_COLLECTION,
rkey,
},
}),
);
const record: StringRecord = {
uri: response.uri,
cid: response.cid ?? '',
rkey,
value: response.value as ShTangledString.Main,
};
return { owner, record };
};
export const getStringRecord = async (uri: ResourceUri): Promise<{ owner: ResolvedActor; record: StringRecord }> => {
const parsed = parseAtUri(uri);
const owner = await resolveActor(parsed.did);
const response = await ok(
getRpc(owner.pds).get('com.atproto.repo.getRecord', {
params: {
repo: owner.did,
collection: STRING_COLLECTION,
rkey: parsed.rkey,
},
}),
);
const record: StringRecord = {
uri: response.uri,
cid: response.cid ?? '',
rkey: parsed.rkey,
value: response.value as ShTangledString.Main,
};
return { owner, record };
};
export const deleteString = async (agent: OAuthUserAgent, rkey: string): Promise<void> => {
const rpc = createAuthRpc(agent);
await ok(
rpc.post('com.atproto.repo.deleteRecord', {
input: {
repo: agent.sub,
collection: STRING_COLLECTION,
rkey,
},
}),
);
};
export const updateString = async (
agent: OAuthUserAgent,
rkey: string,
input: { filename: string; description?: string; contents: string },
swapRecord?: string,
): Promise<RecordCreationResult> => {
const rpc = createAuthRpc(agent);
const record: ShTangledString.Main = {
$type: 'sh.tangled.string',
filename: input.filename.trim(),
description: input.description?.trim() || '',
contents: input.contents,
createdAt: new Date().toISOString(),
};
const result = await ok(
rpc.post('com.atproto.repo.putRecord', {
input: {
repo: agent.sub,
collection: STRING_COLLECTION,
rkey,
record,
swapRecord: swapRecord || undefined,
},
}),
);
return {
uri: result.uri,
rkey: parseAtUri(result.uri).rkey,
};
};
export const createString = async (
agent: OAuthUserAgent,
input: { filename: string; description?: string; contents: string },
): Promise<RecordCreationResult> => {
const rpc = createAuthRpc(agent);
const record: ShTangledString.Main = {
$type: 'sh.tangled.string',
filename: input.filename.trim(),
description: input.description?.trim() || '',
contents: input.contents,
createdAt: new Date().toISOString(),
};
const result = await ok(
rpc.post('com.atproto.repo.createRecord', {
input: {
repo: agent.sub,
collection: STRING_COLLECTION,
record,
},
}),
);
return {
uri: result.uri,
rkey: parseAtUri(result.uri).rkey,
};
};
export const listStringComments = async (stringUri: ResourceUri): Promise<StringComment[]> => {
const records = await listAllAppviewRecords<any>(
'sh.tangled.feed.listComments',
stringUri,
);
const comments = await Promise.all(records.map(async (record) => {
const hydrated = await appviewRecordToHydrated<any>(record);
const val = hydrated.value;
const bodyText = (val && typeof val === 'object' && val.body && typeof val.body === 'object' && 'text' in val.body)
? val.body.text
: (val && typeof val === 'object' && typeof val.body === 'string' ? val.body : '');
return {
...hydrated,
value: {
$type: 'sh.tangled.feed.comment',
body: bodyText,
createdAt: (val && typeof val === 'object' && val.createdAt) || new Date().toISOString(),
subject: (val && typeof val === 'object' && val.subject) || { uri: stringUri, cid: '' },
replyTo: val && typeof val === 'object' ? val.replyTo : undefined,
}
};
}));
comments.sort((a, b) => new Date(a.value.createdAt).getTime() - new Date(b.value.createdAt).getTime());
return comments;
};
export const createStringComment = async (
agent: OAuthUserAgent,
stringUri: ResourceUri,
stringCid: string,
body: string,
replyTo?: { uri: ResourceUri; cid: string },
): Promise<RecordCreationResult> => {
const rpc = createAuthRpc(agent);
const record = {
$type: 'sh.tangled.feed.comment',
subject: {
uri: stringUri,
cid: stringCid,
},
body: {
$type: 'sh.tangled.markup.markdown',
text: body.trim(),
},
createdAt: new Date().toISOString(),
...(replyTo ? {
replyTo: {
uri: replyTo.uri,
cid: replyTo.cid,
}
} : {}),
};
const result = await ok(
rpc.post('com.atproto.repo.createRecord', {
input: {
repo: agent.sub,
collection: FEED_COMMENT_COLLECTION,
record,
},
}),
);
return {
uri: result.uri,
rkey: parseAtUri(result.uri).rkey,
};
};
export const getStringStarSummary = async (
stringUri: ResourceUri,
viewerDid?: Did | null,
): Promise<StringStarSummary> => {
const count = await getDistinctAppviewRecordCount('sh.tangled.feed.countStars', stringUri);
let currentUserStar: AppviewRecordView<ShTangledFeedStar.Main> | undefined;
if (viewerDid) {
const stars = await listAllAppviewRecords<ShTangledFeedStar.Main>('sh.tangled.feed.listStarsBy', viewerDid);
currentUserStar = stars.find((star) => {
const subject = star.value.subject as { uri?: unknown };
return subject?.uri === stringUri;
});
}
return {
count,
isStarred: !!currentUserStar,
currentUserStarRkey: currentUserStar ? parseAtUri(currentUserStar.uri).rkey : undefined,
};
};
export const createStringStar = async (
agent: OAuthUserAgent,
stringUri: ResourceUri,
): Promise<RecordCreationResult> => {
const rpc = createAuthRpc(agent);
const record: ShTangledFeedStar.Main = {
$type: 'sh.tangled.feed.star',
subject: {
$type: 'sh.tangled.feed.star#string',
uri: stringUri,
},
createdAt: new Date().toISOString(),
};
const result = await ok(
rpc.post('com.atproto.repo.createRecord', {
input: {
repo: agent.sub,
collection: STAR_COLLECTION,
record,
},
}),
);
return {
uri: result.uri,
rkey: parseAtUri(result.uri).rkey,
};
};
export const deleteStringStar = async (agent: OAuthUserAgent, rkey: string): Promise<void> => {
const rpc = createAuthRpc(agent);
await ok(
rpc.post('com.atproto.repo.deleteRecord', {
input: {
repo: agent.sub,
collection: STAR_COLLECTION,
rkey,
},
}),
);
};