csr tangled client in solid-js
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387import { ok } from '@atcute/client';import type { ResourceUri } from '@atcute/lexicons/syntax';import type { OAuthUserAgent } from '@atcute/oauth-browser-client';import { ShTangledRepoIssue, ShTangledRepoIssueComment, ShTangledRepoIssueState,} from '@atcute/tangled';
import { appviewFallback, appviewJson, getAppviewRecordCount, listAllAppviewRecords, type AppviewRecordView, type AppviewStatefulRecordView,} from './appview';import { getBacklinks, getLatestBacklinkTimestamp, getUniqueBacklinkRefs, latestBacklinkRefByRkey, listBacklinkedRecordsPage,} from './backlinks';import { ISSUE_COLLECTION, ISSUE_COMMENT_COLLECTION, ISSUE_STATE_COLLECTION,} from './constants';import { appviewRecordToHydrated, fetchRecordValue, hydrateBacklinks, hydrateBacklinksFromAppview,} from './hydration';import { createAuthRpc } from './identity';import { extractAtUris, extractDids, findNumberedRecord, type HydratedRecord, maxTimestamp, optionalString, type PaginatedResult, parseAtUri, parseDirectRecordRef, sortByCreatedAt, timestampFromDate, timestampFromTid, toNumberMap, type RecordCreationResult, type BacklinkRecordRef,} from './records';import type { RepoContext } from './repos';import { getAppviewRecordSortTimestamp, listAppviewStatefulRecordsPage, listSearchedStatefulRecordsPage, sortStatefulRecordsByNewestRecord, sortStatefulRecordsByUpdatedAt, type StatefulPageOptions,} from './stateful';
export interface IssueSummary extends HydratedRecord<ShTangledRepoIssue.Main> { number: number; state: 'open' | 'closed';}
export interface IssueComment extends HydratedRecord<ShTangledRepoIssueComment.Main> {}
export interface IssueDetail { issue: IssueSummary; comments: IssueComment[];}
export interface CreateIssueInput { title: string; body: string;}
const normalizeIssueState = (value?: string): 'open' | 'closed' => value === 'closed' || value?.endsWith('.closed') ? 'closed' : 'open';
export const getIssueRecord = async (issueUri: ResourceUri): Promise<HydratedRecord<ShTangledRepoIssue.Main>> => { const record = await appviewJson<AppviewRecordView<ShTangledRepoIssue.Main>>( 'sh.tangled.repo.getIssue', { issue: issueUri }, ); return appviewRecordToHydrated(record);};
const hydrateIssueBacklinks = (refs: BacklinkRecordRef[]): Promise<Array<HydratedRecord<ShTangledRepoIssue.Main>>> => hydrateBacklinksFromAppview<ShTangledRepoIssue.Main>( refs, 'sh.tangled.repo.getIssues', 'issues', );
const getLatestIssueState = async (issueUri: ResourceUri): Promise<'open' | 'closed'> => { const refs = await getBacklinks(issueUri, 'sh.tangled.repo.issue.state:issue'); if (refs.length === 0) { return 'open'; }
const state = await fetchRecordValue<ShTangledRepoIssueState.Main>(latestBacklinkRefByRkey(refs)!); return normalizeIssueState(state.state);};
const getIssueUpdatedAt = async ( issue: HydratedRecord<ShTangledRepoIssue.Main>, stateUpdatedAt?: string,): Promise<number> => maxTimestamp( timestampFromDate(issue.value.createdAt), timestampFromTid(issue.rkey), timestampFromDate(stateUpdatedAt), await getLatestBacklinkTimestamp(issue.uri, [ `${ISSUE_COMMENT_COLLECTION}:issue`, `${ISSUE_STATE_COLLECTION}:issue`, ]), );
const listIssuesFromBacklinks = async (repo: RepoContext): Promise<IssueSummary[]> => { const refs = await getUniqueBacklinkRefs(repo.repoDid, [ 'sh.tangled.repo.issue:repoDid', 'sh.tangled.repo.issue:repo', ]); const issues = await hydrateIssueBacklinks(refs); const numbers = toNumberMap(issues); const states = await Promise.all(issues.map((issue) => getLatestIssueState(issue.uri)));
const summaries = issues .map((issue, index) => ({ ...issue, number: numbers.get(issue.uri) ?? index + 1, state: states[index], }));
return sortStatefulRecordsByUpdatedAt(summaries, (issue) => getIssueUpdatedAt(issue));};
const listIssuesPageFromBacklinks = async ( repo: RepoContext, options: StatefulPageOptions<'open' | 'closed'>,): Promise<PaginatedResult<IssueSummary>> => listBacklinkedRecordsPage<ShTangledRepoIssue.Main, 'open' | 'closed'>( repo.repoDid, ['sh.tangled.repo.issue:repoDid', 'sh.tangled.repo.issue:repo'], options, getLatestIssueState, (issue) => getIssueUpdatedAt(issue), hydrateIssueBacklinks, );
const getIssueCountFromBacklinks = async (repo: RepoContext): Promise<number> => (await getUniqueBacklinkRefs(repo.repoDid, [ 'sh.tangled.repo.issue:repoDid', 'sh.tangled.repo.issue:repo', ])).length;
const getIssueFromBacklinks = async (repo: RepoContext, issueRef: string): Promise<IssueDetail> => { const issues = await listIssuesFromBacklinks(repo); const issue = findNumberedRecord(issues, issueRef); if (!issue) { throw new Error(`Issue not found`); }
const commentRefs = await getBacklinks(issue.uri, 'sh.tangled.repo.issue.comment:issue'); const comments = await hydrateBacklinks<ShTangledRepoIssueComment.Main>(commentRefs); comments.sort(sortByCreatedAt);
return { issue, comments, };};
const listIssueCommentsFromAppview = async (issueUri: ResourceUri): Promise<IssueComment[]> => { const records = await listAllAppviewRecords<ShTangledRepoIssueComment.Main>( 'sh.tangled.repo.issue.listComments', issueUri, ); const comments = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); comments.sort(sortByCreatedAt); return comments;};
const listIssuesFromAppview = async (repo: RepoContext): Promise<IssueSummary[]> => { const records = await listAllAppviewRecords<ShTangledRepoIssue.Main>( 'sh.tangled.repo.listIssues', repo.repoDid, ) as Array<AppviewStatefulRecordView<ShTangledRepoIssue.Main>>; const issues = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); const numbers = toNumberMap(issues);
const summaries = issues .map((issue, index) => ({ ...issue, number: numbers.get(issue.uri) ?? index + 1, state: normalizeIssueState(records[index].state), }));
return sortStatefulRecordsByNewestRecord(summaries);};
const listIssuesPageFromAppview = async ( repo: RepoContext, options: StatefulPageOptions<'open' | 'closed'>,): Promise<PaginatedResult<IssueSummary>> => { if (options.query?.trim()) { // Appview search can scope to repo/author/nsid. State is still derived // from separate state records, so filter state after each search hit. return listSearchedStatefulRecordsPage<ShTangledRepoIssue.Main, 'open' | 'closed'>( 'sh.tangled.repo.issue', repo, options, getLatestIssueState, ); }
// Temporary until Bobbin exposes newest-first/reverse repo issue pagination. // This loads all appview issue records, but sorts without per-item backlinks. return listAppviewStatefulRecordsPage<ShTangledRepoIssue.Main, 'open' | 'closed'>( 'sh.tangled.repo.listIssues', repo.repoDid, options, normalizeIssueState, options.author ? { author: options.author } : {}, (issue) => Promise.resolve(getAppviewRecordSortTimestamp(issue)), );};
const getIssueFromAppview = async (repo: RepoContext, issueRef: string): Promise<IssueDetail> => { const directUri = parseDirectRecordRef(issueRef, ISSUE_COLLECTION); if (directUri) { const issueRecord = await getIssueRecord(directUri); if (issueRecord.value.repo !== repo.repoDid) { throw new Error(`Issue not found`); }
const issue: IssueSummary = { ...issueRecord, number: 0, state: await getLatestIssueState(issueRecord.uri), };
return { issue, comments: await listIssueCommentsFromAppview(issue.uri), }; }
const issues = await listIssuesFromAppview(repo); const issue = findNumberedRecord(issues, issueRef); if (!issue) { throw new Error(`Issue not found`); }
return { issue, comments: await listIssueCommentsFromAppview(issue.uri), };};
export const listIssues = async (repo: RepoContext): Promise<IssueSummary[]> => appviewFallback( () => listIssuesFromAppview(repo), () => listIssuesFromBacklinks(repo), );
export const listIssuesPage = async ( repo: RepoContext, options: StatefulPageOptions<'open' | 'closed'>,): Promise<PaginatedResult<IssueSummary>> => appviewFallback( () => listIssuesPageFromAppview(repo, options), () => listIssuesPageFromBacklinks(repo, options), );
export const getRepoIssueCount = async (repo: RepoContext): Promise<number> => appviewFallback( () => getAppviewRecordCount('sh.tangled.repo.countIssues', repo.repoDid), () => getIssueCountFromBacklinks(repo), );
export const getIssue = async (repo: RepoContext, issueRef: string): Promise<IssueDetail> => appviewFallback( () => getIssueFromAppview(repo, issueRef), () => getIssueFromBacklinks(repo, issueRef), );
export const createIssue = async ( agent: OAuthUserAgent, repo: RepoContext, input: CreateIssueInput,): Promise<RecordCreationResult> => { const rpc = createAuthRpc(agent); const record: ShTangledRepoIssue.Main = { $type: 'sh.tangled.repo.issue', repo: repo.repoDid, title: input.title.trim(), body: optionalString(input.body), createdAt: new Date().toISOString(), mentions: extractDids(input.body), references: extractAtUris(input.body), };
const result = await ok( rpc.post('com.atproto.repo.createRecord', { input: { repo: agent.sub, collection: ISSUE_COLLECTION, record, }, }), );
return { uri: result.uri, rkey: parseAtUri(result.uri).rkey, };};
export const createIssueComment = async ( agent: OAuthUserAgent, issueUri: ResourceUri, body: string, replyTo?: ResourceUri,): Promise<RecordCreationResult> => { const rpc = createAuthRpc(agent); const record: ShTangledRepoIssueComment.Main = { $type: 'sh.tangled.repo.issue.comment', issue: issueUri, body: body.trim(), createdAt: new Date().toISOString(), mentions: extractDids(body), references: extractAtUris(body), ...(replyTo ? { replyTo } : {}), };
const result = await ok( rpc.post('com.atproto.repo.createRecord', { input: { repo: agent.sub, collection: ISSUE_COMMENT_COLLECTION, record, }, }), );
return { uri: result.uri, rkey: parseAtUri(result.uri).rkey, };};
export const setIssueState = async ( agent: OAuthUserAgent, issueUri: ResourceUri, state: 'open' | 'closed',): Promise<RecordCreationResult> => { const rpc = createAuthRpc(agent); const record: ShTangledRepoIssueState.Main = { $type: 'sh.tangled.repo.issue.state', issue: issueUri, state: state === 'closed' ? 'sh.tangled.repo.issue.state.closed' : 'sh.tangled.repo.issue.state.open', };
const result = await ok( rpc.post('com.atproto.repo.createRecord', { input: { repo: agent.sub, collection: ISSUE_STATE_COLLECTION, record, }, }), );
return { uri: result.uri, rkey: parseAtUri(result.uri).rkey, };};