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> => { 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 => { const actor = typeof owner === 'string' ? await resolveActor(owner) : owner; const records = await listAllAppviewRecords('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 => { 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 => 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 => { 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 => { 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 => { 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 => { const records = await listAllAppviewRecords( 'sh.tangled.feed.listComments', stringUri, ); const comments = await Promise.all(records.map(async (record) => { const hydrated = await appviewRecordToHydrated(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 => { 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 => { const count = await getDistinctAppviewRecordCount('sh.tangled.feed.countStars', stringUri); let currentUserStar: AppviewRecordView | undefined; if (viewerDid) { const stars = await listAllAppviewRecords('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 => { 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 => { const rpc = createAuthRpc(agent); await ok( rpc.post('com.atproto.repo.deleteRecord', { input: { repo: agent.sub, collection: STAR_COLLECTION, rkey, }, }), ); };