From c2cb023b7dbf3920d888a21b912050f6138423ed Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Tue, 9 Jun 2026 14:55:46 -0500 Subject: [PATCH] feat: collection/record routing --- TODO.md | 16 +++--- src/lib/atproto/identity.ts | 15 ++++-- src/lib/atproto/pagination.ts | 2 +- src/lib/atproto/repo.svelte.ts | 54 ++++++++++++++++++- src/lib/atproto/routes.ts | 5 ++ src/lib/components/CollectionBrowser.svelte | 11 ++++ src/routes/+layout.svelte | 39 ++++++++++++++ .../[did]/[collection]/[rkey]/+page.svelte | 5 ++ .../[did]/[collection]/[rkey]/+page.ts | 1 + static/_redirects | 1 + svelte.config.js | 2 +- 11 files changed, 136 insertions(+), 15 deletions(-) create mode 100644 src/lib/atproto/routes.ts create mode 100644 src/routes/records/[did]/[collection]/[rkey]/+page.svelte create mode 100644 src/routes/records/[did]/[collection]/[rkey]/+page.ts create mode 100644 static/_redirects diff --git a/TODO.md b/TODO.md index 907bd74..9b89ca1 100644 --- a/TODO.md +++ b/TODO.md @@ -6,15 +6,13 @@ no authentication or server. ## Routing -- Add shareable record routes: - - `/records/:did/:collection/:rkey` -- Use DIDs, not handles, as canonical URL identity because DIDs are stable. -- When opening a record route directly, boot the full desktop shell, select the - repo/collection in Nautilus, and open the record in gedit. -- Fetch direct record links client-side with public ATProto, likely - `com.atproto.repo.getRecord`. -- Opportunistically hydrate public identity metadata for nicer labels: handle, - display name, avatar, DID, and PDS. +- Done: shareable record routes at `/records/:did/:collection/:rkey`. +- Done: direct record routes boot the desktop shell, select the repo/collection in + Nautilus, and open the record in gedit. +- Done: record routes fetch client-side with public ATProto and use DIDs as the + canonical identity. +- Done: record routes opportunistically hydrate public identity metadata for nicer + labels: handle, display name, avatar, DID, and PDS. ## Browser diff --git a/src/lib/atproto/identity.ts b/src/lib/atproto/identity.ts index 86b3e79..c7a1978 100644 --- a/src/lib/atproto/identity.ts +++ b/src/lib/atproto/identity.ts @@ -26,11 +26,20 @@ export async function resolveAccount(handle: string): Promise { publicApi.get('com.atproto.identity.resolveHandle', { params: { handle: normalizedHandle as Handle } }) ); - const [profile, pds] = await Promise.all([getPublicProfile(identity.did), resolvePds(identity.did)]); + return hydratePublicIdentity(identity.did, normalizedHandle); +} + +export async function hydratePublicIdentity(did: string, fallbackHandle = did): Promise { + if (!did.startsWith('did:')) { + throw new Error('Record routes must include a DID.'); + } + + const [profile, pds] = await Promise.all([getPublicProfile(did).catch(() => null), resolvePds(did).catch(() => null)]); + const handle = profile?.handle ?? fallbackHandle; return { - handle: normalizedHandle, - did: identity.did, + handle, + did, pds, displayName: profile?.displayName ?? null, avatar: profile?.avatar ?? null, diff --git a/src/lib/atproto/pagination.ts b/src/lib/atproto/pagination.ts index 4f7f389..1ca8b99 100644 --- a/src/lib/atproto/pagination.ts +++ b/src/lib/atproto/pagination.ts @@ -18,7 +18,7 @@ export async function* listRecordPages({ do { const response = await ok( rpc.get('com.atproto.repo.listRecords', { - params: { repo: identity.did as ActorIdentifier, collection: collection as Nsid, limit, reverse: true, cursor } + params: { repo: identity.did as ActorIdentifier, collection: collection as Nsid, limit, reverse: false, cursor } }) ); diff --git a/src/lib/atproto/repo.svelte.ts b/src/lib/atproto/repo.svelte.ts index fc3e3d3..2537930 100644 --- a/src/lib/atproto/repo.svelte.ts +++ b/src/lib/atproto/repo.svelte.ts @@ -1,5 +1,5 @@ import { Client, ok, simpleFetchHandler } from '@atcute/client'; -import type { ActorIdentifier } from '@atcute/lexicons/syntax'; +import type { ActorIdentifier, Nsid } from '@atcute/lexicons/syntax'; import type {} from '@atcute/atproto'; import type { CachedRecord, CachedRecordInput } from '$lib/db'; import { errorMessage } from '$lib/utils/errors'; @@ -164,6 +164,47 @@ class RepoBrowserState { } } + async openRecordRoute(identity: AccountIdentity, collectionName: string, rkey: string) { + this.reset(); + this.loadedDid = identity.did; + this.selectedCollection = collectionName; + this.isLoadingCollections = true; + this.isLoadingRecords = true; + this.error = null; + + try { + const rpc = createRepoClient(identity); + const [repo, record] = await Promise.all([ + ok(rpc.get('com.atproto.repo.describeRepo', { params: { repo: identity.did as ActorIdentifier } })), + getRepoRecord(identity, collectionName, rkey) + ]); + const collectionNames = repo.collections.some((name) => name === collectionName) + ? repo.collections + : [collectionName, ...repo.collections]; + + this.collections = collectionNames.sort().map((name) => collectionSummaryForName(name, null)); + this.selectedCollection = collectionName; + this.recordPages = listRecordPages({ identity, collection: collectionName, limit: 25 }); + await this.loadNextRecordPage(identity, { throwOnError: false }); + + const summary = summarizeRecord(record, identity.handle); + this.selectedRecord = summary; + + if (!this.records.some((existingRecord) => existingRecord.uri === summary.uri)) { + this.records = [summary, ...this.records]; + } + + this.collections = this.collections.map((collection) => + collection.name === collectionName ? { ...collection, loadedCount: this.records.length } : collection + ); + } catch (unknownError) { + this.error = errorMessage(unknownError, `Could not open ${collectionName}/${rkey}.`); + } finally { + this.isLoadingCollections = false; + this.isLoadingRecords = false; + } + } + async loadRecordsFromCache(identity: AccountIdentity, collectionName: string) { try { const { getDatabase, listCachedRecords } = await import('$lib/db'); @@ -208,6 +249,17 @@ function createRepoClient(identity: AccountIdentity) { return new Client({ handler: simpleFetchHandler({ service: identity.pds ?? 'https://public.api.bsky.app' }) }); } +async function getRepoRecord(identity: AccountIdentity, collectionName: string, rkey: string): Promise { + const rpc = createRepoClient(identity); + const record = await ok( + rpc.get('com.atproto.repo.getRecord', { + params: { repo: identity.did as ActorIdentifier, collection: collectionName as Nsid, rkey } + }) + ); + + return { uri: record.uri, cid: record.cid ?? '', value: record.value }; +} + function preferredCollection(collections: string[]) { return collections.includes('app.bsky.feed.post') ? 'app.bsky.feed.post' : (collections[0] ?? null); } diff --git a/src/lib/atproto/routes.ts b/src/lib/atproto/routes.ts new file mode 100644 index 0000000..8ed735e --- /dev/null +++ b/src/lib/atproto/routes.ts @@ -0,0 +1,5 @@ +export type RecordRouteParams = { did: string; collection: string; rkey: string }; + +export function recordPath({ did, collection, rkey }: RecordRouteParams) { + return `/records/${did}/${collection}/${encodeURIComponent(rkey)}`; +} diff --git a/src/lib/components/CollectionBrowser.svelte b/src/lib/components/CollectionBrowser.svelte index f51a996..74a1baa 100644 --- a/src/lib/components/CollectionBrowser.svelte +++ b/src/lib/components/CollectionBrowser.svelte @@ -1,7 +1,9 @@ + + diff --git a/src/routes/records/[did]/[collection]/[rkey]/+page.ts b/src/routes/records/[did]/[collection]/[rkey]/+page.ts new file mode 100644 index 0000000..d43d0cd --- /dev/null +++ b/src/routes/records/[did]/[collection]/[rkey]/+page.ts @@ -0,0 +1 @@ +export const prerender = false; diff --git a/static/_redirects b/static/_redirects new file mode 100644 index 0000000..7797f7c --- /dev/null +++ b/static/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/svelte.config.js b/svelte.config.js index 738efe1..4cfe47a 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -6,7 +6,7 @@ const config = { extensions: ['.svelte', '.svx', '.md'], preprocess: [mdsvex({ extensions: ['.svx', '.md'] })], compilerOptions: { runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true) }, - kit: { adapter: adapter() } + kit: { adapter: adapter({ fallback: 'index.html' }) } }; export default config; -- 2.51.2