diff --git a/AGENTS.md b/AGENTS.md index 9e43db8..604028a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,27 +1,5 @@ # Agent Notes -## RTK Commands - -When running shell commands, prefix them with `rtk` by default. If `rtk` has no -filter for a command, it passes through unchanged. - -Examples: - -```bash -rtk git status -rtk git diff -rtk rg "pattern" src -rtk npm run build -``` - -Rules: - -- In command chains, prefix each segment: `rtk git add . && rtk git commit -m "msg"`. -- Use `rtk rg` for searching, including TSX/JSX files. -- Do not use `rtk sed`, `rtk proxy sed`, `rtk cat`, `rtk read`, or other RTK-filtered readers for `.tsx` or `.jsx` files; use raw `sed`, `cat`, or similar so JSX syntax is preserved. -- For debugging, use a raw command without `rtk` if the filter hides needed detail. -- Use `apply_patch` for manual edits. - ## Build Use this as the main verification command: diff --git a/src/App.tsx b/src/App.tsx index e8dc4e9..a7bc8d8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import type { Component } from 'solid-js'; import { AuthProvider } from './lib/auth'; import { LiveEventsProvider } from './lib/live-events'; +import { AppSettingsProvider } from './lib/settings'; import { AppRoutes } from './routes'; const queryClient = new QueryClient({ @@ -16,11 +17,13 @@ const queryClient = new QueryClient({ const App: Component = () => ( - - - - - + + + + + + + ); diff --git a/src/layout.tsx b/src/layout.tsx index 0721b28..d02a209 100644 --- a/src/layout.tsx +++ b/src/layout.tsx @@ -1,14 +1,15 @@ import clsx from 'clsx'; -import { Bell, LogOut } from 'lucide-solid'; +import { Bell, LogOut, RotateCcw, Save, Settings } from 'lucide-solid'; import { A } from '@solidjs/router'; -import { createQuery } from '@tanstack/solid-query'; -import { For, Show, createSignal, type Component, type JSX } from 'solid-js'; +import { createQuery, useQueryClient } from '@tanstack/solid-query'; +import { For, Show, createEffect, createSignal, type Component, type JSX } from 'solid-js'; import { Avatar, buttonStyles, inputStyles } from './components/common'; -import { resolveActor } from './lib/api'; +import { clearApiCaches, resolveActor } from './lib/api'; import { useAuth } from './lib/auth'; import { useLiveEvents, type LiveEvent } from './lib/live-events'; import { formatRelativeTime } from './lib/repo-utils'; +import { useAppSettings } from './lib/settings'; const eventLabel = (event: LiveEvent) => { if (event.source === 'sh.tangled.repo.issue:repo') { @@ -22,6 +23,110 @@ const eventLabel = (event: LiveEvent) => { return `${event.source} -> ${event.subject}`; }; +const AppSettingsMenu: Component = () => { + const settings = useAppSettings(); + const queryClient = useQueryClient(); + const [draftAppviewUrl, setDraftAppviewUrl] = createSignal(settings.appviewUrl()); + const [draftRouteKnotRequestsThroughAppview, setDraftRouteKnotRequestsThroughAppview] = createSignal( + settings.routeKnotRequestsThroughAppview(), + ); + const [error, setError] = createSignal(null); + const [saved, setSaved] = createSignal(false); + + createEffect(() => { + setDraftAppviewUrl(settings.appviewUrl()); + setDraftRouteKnotRequestsThroughAppview(settings.routeKnotRequestsThroughAppview()); + }); + + const reloadAppviewBackedQueries = () => { + clearApiCaches(); + void queryClient.invalidateQueries(); + }; + + const onSave = (event: SubmitEvent) => { + event.preventDefault(); + try { + settings.setAppviewUrl(draftAppviewUrl()); + settings.setRouteKnotRequestsThroughAppview(draftRouteKnotRequestsThroughAppview()); + setError(null); + setSaved(true); + reloadAppviewBackedQueries(); + } catch (cause) { + setSaved(false); + setError(cause instanceof Error ? cause.message : 'Invalid appview URL'); + } + }; + + const onReset = () => { + settings.resetAppviewSettings(); + setDraftAppviewUrl(settings.defaultAppviewUrl); + setDraftRouteKnotRequestsThroughAppview(false); + setError(null); + setSaved(true); + reloadAppviewBackedQueries(); + }; + + return ( +
+ + + +
+
+
+ + { + setDraftAppviewUrl(event.currentTarget.value); + setSaved(false); + setError(null); + }} + placeholder={settings.defaultAppviewUrl} + class={inputStyles()} + /> +
+ + + +
+ + +
+ + +

{error()}

+
+ +

saved

+
+
+
+
+ ); +}; + const Topbar: Component = () => { const auth = useAuth(); const live = useLiveEvents(); @@ -48,6 +153,7 @@ const Topbar: Component = () => {
+ { - const resolved = await ok( - getRpc(SLINGSHOT_SERVICE).get('blue.microcosm.identity.resolveMiniDoc', { - params: { - identifier: actor, - }, - signal: options?.signal, - }), - ); + try { + return normalizeResolvedActor( + await appviewJson( + 'com.bad-example.identity.resolveMiniDoc', + { identifier: actor }, + options?.signal, + ), + ); + } catch (cause) { + if (!(cause instanceof AppviewUnavailableError)) { + throw cause; + } - return { - did: resolved.did, - handle: resolved.handle, - pds: normalizeServiceUrl(resolved.pds), - }; + const resolved = await ok( + getRpc(SLINGSHOT_SERVICE).get('blue.microcosm.identity.resolveMiniDoc', { + params: { + identifier: actor, + }, + signal: options?.signal, + }), + ); + + return normalizeResolvedActor(resolved); + } } } -export const identityResolver = new SlingshotActorResolver(); +export const identityResolver = new AppviewFirstActorResolver(); export interface RepoContext { owner: ResolvedActor; @@ -307,9 +331,55 @@ interface ProfileWithAvatar { avatar?: unknown; } +interface AppviewRecordView { + uri: ResourceUri; + cid?: string | null; + value: T; +} + +interface AppviewListResponse { + items: Array>; + cursor?: string | null; +} + +interface AppviewStatefulRecordView extends AppviewRecordView { + state?: string; + stateUpdatedAt?: string; + commentCount?: number; +} + +interface AppviewStatefulListResponse { + items: Array>; + cursor?: string | null; +} + +interface AppviewCountResponse { + count: number; + distinctAuthors?: number; + distinct_authors?: number; +} + +type AppviewParamValue = string | number | boolean | null | undefined; + const actorCache = new Map>(); const rpcCache = new Map(); +interface AppviewCursorCheckpoint { + matchingSeen: number; + scannedSeen: number; + cursor?: string; + exhausted: boolean; +} + +const APPVIEW_PAGE_BATCH_LIMIT = 100; +const APPVIEW_CURSOR_CACHE_LIMIT = 64; +const appviewPageCursorCache = new Map>(); + +export const clearApiCaches = () => { + actorCache.clear(); + appviewPageCursorCache.clear(); +}; + const normalizeServiceUrl = (input: string): string => { if (input.startsWith('http://') || input.startsWith('https://')) { return input; @@ -318,6 +388,26 @@ const normalizeServiceUrl = (input: string): string => { return `https://${input}`; }; +const normalizeResolvedActor = (resolved: MiniDocResponse): ResolvedActor => ({ + did: resolved.did, + handle: resolved.handle, + pds: normalizeServiceUrl(resolved.pds), +}); + +const unresolvedActorFromDid = (did: Did): ResolvedActor => ({ + did, + handle: did as unknown as Handle, + pds: '', +}); + +const resolveActorForDisplay = async (did: Did): Promise => { + try { + return await resolveActor(did); + } catch { + return unresolvedActorFromDid(did); + } +}; + const getRpc = (service: string): Client => { const normalized = normalizeServiceUrl(service); const existing = rpcCache.get(normalized); @@ -335,6 +425,315 @@ const getRpc = (service: string): Client => { return client; }; +class AppviewResponseError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = 'AppviewResponseError'; + this.status = status; + } +} + +class AppviewUnavailableError extends Error { + readonly cause: unknown; + + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : 'Appview unavailable'); + this.name = 'AppviewUnavailableError'; + this.cause = cause; + } +} + +const appviewJson = async ( + nsid: string, + params: Record = {}, + signal?: AbortSignal, +): Promise => { + const url = new URL(`/xrpc/${nsid}`, normalizeServiceUrl(getTangledAppviewService())); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) { + url.searchParams.set(key, String(value)); + } + } + + let response: Response; + try { + response = await fetch(url, { + headers: { + accept: 'application/json', + }, + signal, + }); + } catch (cause) { + throw new AppviewUnavailableError(cause); + } + + if (!response.ok) { + let message = `${response.status} ${response.statusText}`.trim(); + try { + const body = (await response.json()) as { message?: unknown; error?: unknown }; + const bodyMessage = typeof body.message === 'string' ? body.message : body.error; + if (typeof bodyMessage === 'string') { + message = bodyMessage; + } + } catch { + // Keep the HTTP status message when the appview does not return JSON. + } + + throw new AppviewResponseError(response.status, `Appview ${nsid} failed (${response.status}): ${message}`); + } + + return (await response.json()) as T; +}; + +const appviewFallback = async (primary: () => Promise, fallback: () => Promise): Promise => { + try { + return await primary(); + } catch (cause) { + if (!(cause instanceof AppviewUnavailableError)) { + throw cause; + } + + return fallback(); + } +}; + +const listAllAppviewRecords = async ( + nsid: string, + subject: string, + extraParams: Record = {}, +): Promise>> => { + const records: Array> = []; + let cursor: string | undefined; + + do { + const page = await appviewJson>(nsid, { + subject, + limit: 100, + cursor, + ...extraParams, + }); + records.push(...page.items); + cursor = page.cursor ?? undefined; + } while (cursor); + + return records; +}; + +const appviewRecordToHydrated = async (record: AppviewRecordView): Promise> => { + const parsed = parseAtUri(record.uri); + const author = await resolveActorForDisplay(parsed.did); + + return { + author, + collection: parsed.collection, + rkey: parsed.rkey, + uri: record.uri, + cid: record.cid ?? undefined, + value: record.value, + }; +}; + +const appviewCursorCacheKey = ( + nsid: string, + subject: string, + state: string, + extraParams: Record = {}, +): string => { + const extras = Object.entries(extraParams) + .filter(([, value]) => value !== undefined && value !== null) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${String(value)}`) + .join('&'); + + return [ + normalizeServiceUrl(getTangledAppviewService()), + nsid, + subject, + state, + extras, + ].join('|'); +}; + +const rememberAppviewCursorCheckpoint = (cacheKey: string, checkpoint: AppviewCursorCheckpoint) => { + let checkpoints = appviewPageCursorCache.get(cacheKey); + if (!checkpoints) { + checkpoints = new Map(); + appviewPageCursorCache.set(cacheKey, checkpoints); + } + + const existing = checkpoints.get(checkpoint.matchingSeen); + if (!existing || checkpoint.scannedSeen >= existing.scannedSeen) { + checkpoints.set(checkpoint.matchingSeen, checkpoint); + } + + while (checkpoints.size > APPVIEW_CURSOR_CACHE_LIMIT) { + const first = checkpoints.keys().next().value; + if (first === undefined) break; + checkpoints.delete(first); + } +}; + +const getNearestAppviewCursorCheckpoint = ( + cacheKey: string, + offset: number, +): AppviewCursorCheckpoint => { + const checkpoints = appviewPageCursorCache.get(cacheKey); + const start: AppviewCursorCheckpoint = { + matchingSeen: 0, + scannedSeen: 0, + exhausted: false, + }; + + if (!checkpoints) { + return start; + } + + let nearest = start; + for (const checkpoint of checkpoints.values()) { + if (checkpoint.matchingSeen > offset) { + continue; + } + + if ( + checkpoint.matchingSeen > nearest.matchingSeen || + ( + checkpoint.matchingSeen === nearest.matchingSeen && + checkpoint.scannedSeen > nearest.scannedSeen + ) + ) { + nearest = checkpoint; + } + } + + return nearest; +}; + +const listAppviewStatefulRecordsPage = async ( + nsid: string, + subject: string, + options: { offset: number; limit: number; state: State }, + normalizeState: (value?: string) => State, + extraParams: Record = {}, +): Promise & { number: number; state: State }>> => { + // Bobbin exposes cursor pagination, while the UI still uses offsets. Cache + // page-boundary cursors so sequential pages can resume from the prior page. + const offset = Math.max(options.offset, 0); + const limit = Math.max(options.limit, 1); + const requestedEnd = offset + limit; + const cacheKey = appviewCursorCacheKey(nsid, subject, options.state, extraParams); + const start = getNearestAppviewCursorCheckpoint(cacheKey, offset); + const pendingItems: Array<{ + record: AppviewStatefulRecordView; + number: number; + state: State; + }> = []; + + if (start.exhausted) { + return { + items: [], + totalCount: start.matchingSeen, + hasNext: false, + }; + } + + let cursor = start.cursor; + let matchingSeen = start.matchingSeen; + let scannedSeen = start.scannedSeen; + + while (true) { + const pageLimit = Math.min( + APPVIEW_PAGE_BATCH_LIMIT, + Math.max(matchingSeen < requestedEnd ? requestedEnd - matchingSeen : 1, 1), + ); + const page = await appviewJson>(nsid, { + subject, + limit: pageLimit, + cursor, + ...extraParams, + }); + + for (const record of page.items) { + const state = normalizeState(record.state); + const number = scannedSeen + 1; + scannedSeen += 1; + + if (state !== options.state) { + continue; + } + + if (matchingSeen >= offset && matchingSeen < requestedEnd) { + pendingItems.push({ record, number, state }); + } + + matchingSeen += 1; + } + + cursor = page.cursor ?? undefined; + const exhausted = !cursor; + rememberAppviewCursorCheckpoint(cacheKey, { + matchingSeen, + scannedSeen, + cursor, + exhausted, + }); + + if (matchingSeen > requestedEnd) { + const visibleItems = pendingItems.slice(0, limit); + const hydrated = await Promise.all( + visibleItems.map(({ record }) => appviewRecordToHydrated(record)), + ); + + return { + items: hydrated.map((item, index) => ({ + ...item, + number: visibleItems[index].number, + state: visibleItems[index].state, + })), + hasNext: true, + }; + } + + if (exhausted || page.items.length === 0) { + const hydrated = await Promise.all( + pendingItems.map(({ record }) => appviewRecordToHydrated(record)), + ); + + return { + items: hydrated.map((item, index) => ({ + ...item, + number: pendingItems[index].number, + state: pendingItems[index].state, + })), + totalCount: matchingSeen, + hasNext: false, + }; + } + } +}; + +const getRepoViaAppview = async ( + nsid: string, + repo: RepoContext, + params: Record = {}, +): Promise => + appviewJson(nsid, { + repo: repo.record.uri, + ...params, + }); + +const appviewOptionalKnotRequest = async ( + appview: () => Promise, + direct: () => Promise, +): Promise => { + if (!getRouteKnotRequestsThroughAppview()) { + return direct(); + } + + return appviewFallback(appview, direct); +}; + export const createAuthRpc = (agent: OAuthUserAgent): Client => new Client({ handler: agent, @@ -380,7 +779,19 @@ const fetchAllRepoRecords = async ( return records; }; -export const listRepoRecords = async (owner: string | ResolvedActor): Promise => { +const listRepoRecordsFromAppview = async (owner: string | ResolvedActor): Promise => { + const actor = typeof owner === 'string' ? await resolveActor(owner) : owner; + const records = await listAllAppviewRecords('sh.tangled.repo.listRepos', actor.did); + + return records.map((record) => ({ + uri: record.uri, + cid: record.cid ?? '', + rkey: parseAtUri(record.uri).rkey, + value: record.value, + })); +}; + +const listRepoRecordsFromPds = async (owner: string | ResolvedActor): Promise => { const actor = typeof owner === 'string' ? await resolveActor(owner) : owner; const records = await fetchAllRepoRecords(actor, REPO_COLLECTION); @@ -392,6 +803,12 @@ export const listRepoRecords = async (owner: string | ResolvedActor): Promise => + appviewFallback( + () => listRepoRecordsFromAppview(owner), + () => listRepoRecordsFromPds(owner), + ); + export interface RepoStarSummary { count: number; isStarred: boolean; @@ -403,7 +820,7 @@ const STAR_BACKLINK_SOURCES = [ 'sh.tangled.feed.star:subjectDid', ]; -export const getRepoStarSummary = async (repo: RepoContext, viewerDid?: Did | null): Promise => { +const getRepoStarSummaryFromBacklinks = async (repo: RepoContext, viewerDid?: Did | null): Promise => { const starrers = new Set(); const backlinkDidGroups = await Promise.all(STAR_BACKLINK_SOURCES.map((source) => getBacklinkDids(repo.repoDid, source))); @@ -423,11 +840,60 @@ export const getRepoStarSummary = async (repo: RepoContext, viewerDid?: Did | nu }; }; -export const getRepoStarCount = async (repo: RepoContext): Promise => { - const summary = await getRepoStarSummary(repo); +const getRepoStarCountFromBacklinks = async (repo: RepoContext): Promise => { + const summary = await getRepoStarSummaryFromBacklinks(repo); return summary.count; }; +const appviewStarSubjectDid = (star: ShTangledFeedStar.Main): Did | null => { + const subject = star.subject as { did?: unknown; subjectDid?: unknown }; + const did = subject.did ?? subject.subjectDid; + return typeof did === 'string' && did.startsWith('did:') ? (did as Did) : null; +}; + +const getRepoStarCountFromAppview = async (repo: RepoContext): Promise => { + const count = await appviewJson('sh.tangled.feed.countStars', { + subject: repo.repoDid, + }); + return count.distinctAuthors ?? count.distinct_authors ?? count.count; +}; + +const findCurrentUserStarFromAppview = async ( + repo: RepoContext, + viewerDid: Did, +): Promise | undefined> => { + const stars = await listAllAppviewRecords('sh.tangled.feed.listStarsBy', viewerDid); + return stars.find((star) => appviewStarSubjectDid(star.value) === repo.repoDid); +}; + +const getRepoStarSummaryFromAppview = async ( + repo: RepoContext, + viewerDid?: Did | null, +): Promise => { + const [count, currentUserStar] = await Promise.all([ + getRepoStarCountFromAppview(repo), + viewerDid ? findCurrentUserStarFromAppview(repo, viewerDid) : undefined, + ]); + + return { + count, + isStarred: !!currentUserStar, + currentUserStarRkey: currentUserStar ? parseAtUri(currentUserStar.uri).rkey : undefined, + }; +}; + +export const getRepoStarSummary = async (repo: RepoContext, viewerDid?: Did | null): Promise => + appviewFallback( + () => getRepoStarSummaryFromAppview(repo, viewerDid), + () => getRepoStarSummaryFromBacklinks(repo, viewerDid), + ); + +export const getRepoStarCount = async (repo: RepoContext): Promise => + appviewFallback( + () => getRepoStarCountFromAppview(repo), + () => getRepoStarCountFromBacklinks(repo), + ); + export const createRepoStar = async (agent: OAuthUserAgent, repo: RepoContext): Promise => { const rpc = createAuthRpc(agent); const record: ShTangledFeedStar.Main = { @@ -496,7 +962,7 @@ export const getRepo = async (ownerIdentifier: string, repoSlug: string): Promis }; }; -export const getRepoTree = async ( +const getRepoTreeFromKnot = async ( repo: RepoContext, ref: string, path = '', @@ -511,7 +977,7 @@ export const getRepoTree = async ( }), )) as TreeResponse; -export const getRepoBranches = async (repo: RepoContext): Promise => +const getRepoBranchesFromKnot = async (repo: RepoContext): Promise => (await ok( getRpc(repo.knot).get('sh.tangled.repo.branches', { params: { @@ -521,7 +987,7 @@ export const getRepoBranches = async (repo: RepoContext): Promise => +const getRepoDefaultBranchFromKnot = async (repo: RepoContext): Promise => (await ok( getRpc(repo.knot).get('sh.tangled.repo.getDefaultBranch', { params: { @@ -530,7 +996,7 @@ export const getRepoDefaultBranch = async (repo: RepoContext): Promise => +const getRepoTagsFromKnot = async (repo: RepoContext): Promise => (await ok( getRpc(repo.knot).get('sh.tangled.repo.tags', { params: { @@ -540,7 +1006,7 @@ export const getRepoTags = async (repo: RepoContext): Promise => }), )) as TagResponse; -export const getRepoLanguages = async (repo: RepoContext, ref: string): Promise => +const getRepoLanguagesFromKnot = async (repo: RepoContext, ref: string): Promise => (await ok( getRpc(repo.knot).get('sh.tangled.repo.languages', { params: { @@ -550,7 +1016,7 @@ export const getRepoLanguages = async (repo: RepoContext, ref: string): Promise< }), )) as LanguageResponse; -export const getRepoLog = async (repo: RepoContext, ref: string): Promise => +const getRepoLogFromKnot = async (repo: RepoContext, ref: string): Promise => (await ok( getRpc(repo.knot).get('sh.tangled.repo.log', { params: { @@ -562,7 +1028,7 @@ export const getRepoLog = async (repo: RepoContext, ref: string): Promise => + appviewOptionalKnotRequest( + () => getRepoViaAppview('sh.tangled.repo.tree', repo, { ref, path }), + () => getRepoTreeFromKnot(repo, ref, path), + ); + +export const getRepoBranches = async (repo: RepoContext): Promise => + appviewOptionalKnotRequest( + () => getRepoViaAppview('sh.tangled.repo.branches', repo), + () => getRepoBranchesFromKnot(repo), + ); + +export const getRepoDefaultBranch = async (repo: RepoContext): Promise => + appviewOptionalKnotRequest( + () => getRepoViaAppview('sh.tangled.repo.getDefaultBranch', repo), + () => getRepoDefaultBranchFromKnot(repo), + ); + +export const getRepoTags = async (repo: RepoContext): Promise => + appviewOptionalKnotRequest( + () => getRepoViaAppview('sh.tangled.repo.tags', repo), + () => getRepoTagsFromKnot(repo), + ); + +export const getRepoLanguages = async (repo: RepoContext, ref: string): Promise => + appviewOptionalKnotRequest( + () => getRepoViaAppview('sh.tangled.repo.languages', repo, { ref }), + () => getRepoLanguagesFromKnot(repo, ref), + ); + +export const getRepoLog = async (repo: RepoContext, ref: string): Promise => + appviewOptionalKnotRequest( + () => getRepoViaAppview('sh.tangled.repo.log', repo, { ref, limit: 8 }), + () => getRepoLogFromKnot(repo, ref), + ); + +export const getRepoBlob = async ( + repo: RepoContext, + ref: string, + path: string, +): Promise => + appviewOptionalKnotRequest( + () => getRepoViaAppview('sh.tangled.repo.blob', repo, { ref, path }), + () => getRepoBlobFromKnot(repo, ref, path), + ); + +export const compareBranches = async ( + repo: RepoContext, + rev1: string, + rev2: string, +): Promise => + appviewOptionalKnotRequest( + () => getRepoViaAppview('sh.tangled.repo.compare', repo, { rev1, rev2 }), + () => compareBranchesFromKnot(repo, rev1, rev2), + ); + const getBacklinksPage = async ( subject: GenericUri, source: string, @@ -702,7 +1228,7 @@ const hydrateRecord = async ( ): Promise> => { const actor = await resolveActor(ref.did); const record = await ok( - getRpc(SLINGSHOT_SERVICE).get('com.atproto.repo.getRecord', { + getRpc(actor.pds).get('com.atproto.repo.getRecord', { params: { repo: ref.did, collection: ref.collection, @@ -724,8 +1250,9 @@ const hydrateRecord = async ( const fetchRecordValue = async ( ref: BacklinkRecordRef | { did: Did; collection: Nsid; rkey: string }, ): Promise => { + const actor = await resolveActor(ref.did); const record = await ok( - getRpc(SLINGSHOT_SERVICE).get('com.atproto.repo.getRecord', { + getRpc(actor.pds).get('com.atproto.repo.getRecord', { params: { repo: ref.did, collection: ref.collection, @@ -747,7 +1274,7 @@ const getOptionalRecord = async ( ): Promise => { try { const record = await ok( - getRpc(SLINGSHOT_SERVICE).get('com.atproto.repo.getRecord', { + getRpc(actor.pds).get('com.atproto.repo.getRecord', { params: { repo: actor.did, collection, @@ -786,14 +1313,14 @@ const toNumberMap = - value?.endsWith('.closed') ? 'closed' : 'open'; + value === 'closed' || value?.endsWith('.closed') ? 'closed' : 'open'; const normalizePullStatus = (value?: string): 'open' | 'closed' | 'merged' => { - if (value?.endsWith('.merged')) { + if (value === 'merged' || value?.endsWith('.merged')) { return 'merged'; } - if (value?.endsWith('.closed')) { + if (value === 'closed' || value?.endsWith('.closed')) { return 'closed'; } @@ -917,7 +1444,7 @@ const getLatestPullStatus = async (pullUri: ResourceUri): Promise<'open' | 'clos return normalizePullStatus(status.status); }; -export const listIssues = async (repo: RepoContext): Promise => { +const listIssuesFromBacklinks = async (repo: RepoContext): Promise => { const refs = Array.from( new Map( ( @@ -943,7 +1470,7 @@ export const listIssues = async (repo: RepoContext): Promise => .sort((left, right) => right.number - left.number); }; -export const listIssuesPage = async ( +const listIssuesPageFromBacklinks = async ( repo: RepoContext, options: { offset: number; limit: number; state: 'open' | 'closed' }, ): Promise> => @@ -954,8 +1481,8 @@ export const listIssuesPage = async ( getLatestIssueState, ); -export const getIssue = async (repo: RepoContext, issueRef: string): Promise => { - const issues = await listIssues(repo); +const getIssueFromBacklinks = async (repo: RepoContext, issueRef: string): Promise => { + const issues = await listIssuesFromBacklinks(repo); const issue = findNumberedRecord(issues, issueRef); if (!issue) { throw new Error(`Issue not found`); @@ -971,7 +1498,7 @@ export const getIssue = async (repo: RepoContext, issueRef: string): Promise => { +const listPullsFromBacklinks = async (repo: RepoContext): Promise => { const refs = await getBacklinks(repo.repoDid, 'sh.tangled.repo.pull:target.repo'); const pulls = await hydrateBacklinks(refs); const numbers = toNumberMap(pulls); @@ -986,7 +1513,7 @@ export const listPulls = async (repo: RepoContext): Promise => { .sort((left, right) => right.number - left.number); }; -export const listPullsPage = async ( +const listPullsPageFromBacklinks = async ( repo: RepoContext, options: { offset: number; limit: number; state: 'open' | 'closed' | 'merged' }, ): Promise> => @@ -997,8 +1524,8 @@ export const listPullsPage = async ( getLatestPullStatus, ); -export const getPull = async (repo: RepoContext, pullRef: string): Promise => { - const pulls = await listPulls(repo); +const getPullFromBacklinks = async (repo: RepoContext, pullRef: string): Promise => { + const pulls = await listPullsFromBacklinks(repo); const pull = findNumberedRecord(pulls, pullRef); if (!pull) { throw new Error(`Pull request not found`); @@ -1014,6 +1541,150 @@ export const getPull = async (repo: RepoContext, pullRef: string): Promise => { + const records = await listAllAppviewRecords( + 'sh.tangled.repo.issue.listComments', + issueUri, + ); + const comments = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); + comments.sort(sortByCreatedAt); + return comments; +}; + +const listPullCommentsFromAppview = async (pullUri: ResourceUri): Promise => { + const records = await listAllAppviewRecords( + 'sh.tangled.repo.pull.listComments', + pullUri, + ); + const comments = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); + comments.sort(sortByCreatedAt); + return comments; +}; + +const listIssuesFromAppview = async (repo: RepoContext): Promise => { + const records = await listAllAppviewRecords( + 'sh.tangled.repo.listIssues', + repo.repoDid, + ) as Array>; + const issues = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); + const numbers = toNumberMap(issues); + + return issues + .map((issue, index) => ({ + ...issue, + number: numbers.get(issue.uri) ?? index + 1, + state: normalizeIssueState(records[index].state), + })) + .sort((left, right) => right.number - left.number); +}; + +const listIssuesPageFromAppview = async ( + repo: RepoContext, + options: { offset: number; limit: number; state: 'open' | 'closed' }, +): Promise> => + listAppviewStatefulRecordsPage( + 'sh.tangled.repo.listIssues', + repo.repoDid, + options, + normalizeIssueState, + ); + +const getIssueFromAppview = async (repo: RepoContext, issueRef: string): Promise => { + 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), + }; +}; + +const listPullsFromAppview = async (repo: RepoContext): Promise => { + const records = await listAllAppviewRecords( + 'sh.tangled.repo.listPulls', + repo.repoDid, + ) as Array>; + const pulls = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); + const numbers = toNumberMap(pulls); + + return pulls + .map((pull, index) => ({ + ...pull, + number: numbers.get(pull.uri) ?? index + 1, + state: normalizePullStatus(records[index].state), + })) + .sort((left, right) => right.number - left.number); +}; + +const listPullsPageFromAppview = async ( + repo: RepoContext, + options: { offset: number; limit: number; state: 'open' | 'closed' | 'merged' }, +): Promise> => + listAppviewStatefulRecordsPage( + 'sh.tangled.repo.listPulls', + repo.repoDid, + options, + normalizePullStatus, + ); + +const getPullFromAppview = async (repo: RepoContext, pullRef: string): Promise => { + const pulls = await listPullsFromAppview(repo); + const pull = findNumberedRecord(pulls, pullRef); + if (!pull) { + throw new Error(`Pull request not found`); + } + + return { + pull, + comments: await listPullCommentsFromAppview(pull.uri), + }; +}; + +export const listIssues = async (repo: RepoContext): Promise => + appviewFallback( + () => listIssuesFromAppview(repo), + () => listIssuesFromBacklinks(repo), + ); + +export const listIssuesPage = async ( + repo: RepoContext, + options: { offset: number; limit: number; state: 'open' | 'closed' }, +): Promise> => + appviewFallback( + () => listIssuesPageFromAppview(repo, options), + () => listIssuesPageFromBacklinks(repo, options), + ); + +export const getIssue = async (repo: RepoContext, issueRef: string): Promise => + appviewFallback( + () => getIssueFromAppview(repo, issueRef), + () => getIssueFromBacklinks(repo, issueRef), + ); + +export const listPulls = async (repo: RepoContext): Promise => + appviewFallback( + () => listPullsFromAppview(repo), + () => listPullsFromBacklinks(repo), + ); + +export const listPullsPage = async ( + repo: RepoContext, + options: { offset: number; limit: number; state: 'open' | 'closed' | 'merged' }, +): Promise> => + appviewFallback( + () => listPullsPageFromAppview(repo, options), + () => listPullsPageFromBacklinks(repo, options), + ); + +export const getPull = async (repo: RepoContext, pullRef: string): Promise => + appviewFallback( + () => getPullFromAppview(repo, pullRef), + () => getPullFromBacklinks(repo, pullRef), + ); + export const fetchPullRoundPatch = async ( pull: PullSummary, roundIndex: number, @@ -1028,11 +1699,12 @@ export const fetchPullRoundPatch = async ( throw new Error(`Missing patch blob CID`); } - const rpc = getRpc(pull.author.pds); + const author = pull.author.pds ? pull.author : await resolveActor(pull.author.did); + const rpc = getRpc(author.pds); const bytes = await ok( rpc.get('com.atproto.sync.getBlob', { params: { - did: pull.author.did, + did: author.did, cid, }, as: 'bytes', @@ -1282,13 +1954,32 @@ export const buildBlobDataUrl = (blob: BlobResponse): string | null => { return `data:${blob.mimeType};charset=utf-8,${encodeURIComponent(blob.content)}`; }; +const getActorProfileFromAppview = async (actor: ResolvedActor): Promise => { + const profile = await appviewJson>( + 'sh.tangled.actor.getProfile', + { + actor: `at://${actor.did}/${ACTOR_PROFILE_COLLECTION}/self`, + }, + ); + return profile.value; +}; + export const resolveAvatarUrl = async (identifier: string): Promise => { const actor = await resolveActor(identifier); - const tangledProfile = await getOptionalRecord( - actor, - ACTOR_PROFILE_COLLECTION, - 'self', - ); + let tangledProfile: ShTangledActorProfile.Main | null; + try { + tangledProfile = await getActorProfileFromAppview(actor); + } catch (cause) { + if (!(cause instanceof AppviewUnavailableError)) { + throw cause; + } + + tangledProfile = await getOptionalRecord( + actor, + ACTOR_PROFILE_COLLECTION, + 'self', + ); + } const tangledCid = extractBlobCid(tangledProfile?.avatar); if (tangledCid) { return buildPdsBlobUrl(actor.pds, actor.did, tangledCid); diff --git a/src/lib/api/identity.ts b/src/lib/api/identity.ts index 8761260..fc011cb 100644 --- a/src/lib/api/identity.ts +++ b/src/lib/api/identity.ts @@ -1,4 +1,5 @@ export { + clearApiCaches, createAuthRpc, identityResolver, resolveActor, diff --git a/src/lib/settings.tsx b/src/lib/settings.tsx new file mode 100644 index 0000000..9578ff2 --- /dev/null +++ b/src/lib/settings.tsx @@ -0,0 +1,124 @@ +import { createContext, createSignal, useContext, type Component, type JSX } from 'solid-js'; + +const APPVIEW_URL_KEY = 'untangled.settings.appviewUrl'; +const ROUTE_KNOT_REQUESTS_THROUGH_APPVIEW_KEY = 'untangled.settings.routeKnotRequestsThroughAppview'; + +export const DEFAULT_TANGLED_APPVIEW_SERVICE = + import.meta.env.VITE_TANGLED_APPVIEW_SERVICE || 'https://bobbin.klbr.net'; + +export const normalizeAppviewUrl = (input: string): string => { + const trimmed = input.trim(); + const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + const url = new URL(withScheme); + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new Error('Appview URL must use http or https'); + } + + url.hash = ''; + url.search = ''; + return url.toString().replace(/\/$/, ''); +}; + +const readStoredAppviewUrl = (): string => { + if (typeof localStorage === 'undefined') { + return normalizeAppviewUrl(DEFAULT_TANGLED_APPVIEW_SERVICE); + } + + const stored = localStorage.getItem(APPVIEW_URL_KEY); + if (!stored) { + return normalizeAppviewUrl(DEFAULT_TANGLED_APPVIEW_SERVICE); + } + + try { + return normalizeAppviewUrl(stored); + } catch { + localStorage.removeItem(APPVIEW_URL_KEY); + return normalizeAppviewUrl(DEFAULT_TANGLED_APPVIEW_SERVICE); + } +}; + +const readStoredRouteKnotRequestsThroughAppview = (): boolean => { + if (typeof localStorage === 'undefined') { + return false; + } + + return localStorage.getItem(ROUTE_KNOT_REQUESTS_THROUGH_APPVIEW_KEY) === 'true'; +}; + +let currentAppviewUrl = readStoredAppviewUrl(); +let currentRouteKnotRequestsThroughAppview = readStoredRouteKnotRequestsThroughAppview(); + +export const getTangledAppviewService = (): string => currentAppviewUrl; +export const getRouteKnotRequestsThroughAppview = (): boolean => currentRouteKnotRequestsThroughAppview; + +interface AppSettingsContextValue { + appviewUrl: () => string; + routeKnotRequestsThroughAppview: () => boolean; + defaultAppviewUrl: string; + setAppviewUrl: (url: string) => void; + setRouteKnotRequestsThroughAppview: (enabled: boolean) => void; + resetAppviewUrl: () => void; + resetAppviewSettings: () => void; +} + +const AppSettingsContext = createContext(); + +export const AppSettingsProvider: Component<{ children: JSX.Element }> = (props) => { + const [appviewUrl, setAppviewUrlSignal] = createSignal(currentAppviewUrl); + const [routeKnotRequestsThroughAppview, setRouteKnotRequestsThroughAppviewSignal] = createSignal( + currentRouteKnotRequestsThroughAppview, + ); + const defaultAppviewUrl = normalizeAppviewUrl(DEFAULT_TANGLED_APPVIEW_SERVICE); + + const setAppviewUrl = (url: string) => { + const normalized = normalizeAppviewUrl(url); + currentAppviewUrl = normalized; + localStorage.setItem(APPVIEW_URL_KEY, normalized); + setAppviewUrlSignal(normalized); + }; + + const setRouteKnotRequestsThroughAppview = (enabled: boolean) => { + currentRouteKnotRequestsThroughAppview = enabled; + localStorage.setItem(ROUTE_KNOT_REQUESTS_THROUGH_APPVIEW_KEY, String(enabled)); + setRouteKnotRequestsThroughAppviewSignal(enabled); + }; + + const resetAppviewUrl = () => { + currentAppviewUrl = defaultAppviewUrl; + localStorage.removeItem(APPVIEW_URL_KEY); + setAppviewUrlSignal(defaultAppviewUrl); + }; + + const resetAppviewSettings = () => { + resetAppviewUrl(); + currentRouteKnotRequestsThroughAppview = false; + localStorage.removeItem(ROUTE_KNOT_REQUESTS_THROUGH_APPVIEW_KEY); + setRouteKnotRequestsThroughAppviewSignal(false); + }; + + return ( + + {props.children} + + ); +}; + +export const useAppSettings = (): AppSettingsContextValue => { + const context = useContext(AppSettingsContext); + if (!context) { + throw new Error('useAppSettings must be used inside AppSettingsProvider'); + } + + return context; +};