browse the protocol like its 2008 ibex.desertthunder.dev
ubuntu atproto svelte
7

Configure Feed

Select the types of activity you want to include in your feed.

feat: collection/record routing

Owais Jamil (Jun 9, 2026, 2:55 PM -0500) c2cb023b ee1de4a3

+136 -15
+7 -9
TODO.md
··· 6 6 7 7 ## Routing 8 8 9 - - Add shareable record routes: 10 - - `/records/:did/:collection/:rkey` 11 - - Use DIDs, not handles, as canonical URL identity because DIDs are stable. 12 - - When opening a record route directly, boot the full desktop shell, select the 13 - repo/collection in Nautilus, and open the record in gedit. 14 - - Fetch direct record links client-side with public ATProto, likely 15 - `com.atproto.repo.getRecord`. 16 - - Opportunistically hydrate public identity metadata for nicer labels: handle, 17 - display name, avatar, DID, and PDS. 9 + - Done: shareable record routes at `/records/:did/:collection/:rkey`. 10 + - Done: direct record routes boot the desktop shell, select the repo/collection in 11 + Nautilus, and open the record in gedit. 12 + - Done: record routes fetch client-side with public ATProto and use DIDs as the 13 + canonical identity. 14 + - Done: record routes opportunistically hydrate public identity metadata for nicer 15 + labels: handle, display name, avatar, DID, and PDS. 18 16 19 17 ## Browser 20 18
+12 -3
src/lib/atproto/identity.ts
··· 26 26 publicApi.get('com.atproto.identity.resolveHandle', { params: { handle: normalizedHandle as Handle } }) 27 27 ); 28 28 29 - const [profile, pds] = await Promise.all([getPublicProfile(identity.did), resolvePds(identity.did)]); 29 + return hydratePublicIdentity(identity.did, normalizedHandle); 30 + } 31 + 32 + export async function hydratePublicIdentity(did: string, fallbackHandle = did): Promise<AccountIdentity> { 33 + if (!did.startsWith('did:')) { 34 + throw new Error('Record routes must include a DID.'); 35 + } 36 + 37 + const [profile, pds] = await Promise.all([getPublicProfile(did).catch(() => null), resolvePds(did).catch(() => null)]); 38 + const handle = profile?.handle ?? fallbackHandle; 30 39 31 40 return { 32 - handle: normalizedHandle, 33 - did: identity.did, 41 + handle, 42 + did, 34 43 pds, 35 44 displayName: profile?.displayName ?? null, 36 45 avatar: profile?.avatar ?? null,
+1 -1
src/lib/atproto/pagination.ts
··· 18 18 do { 19 19 const response = await ok( 20 20 rpc.get('com.atproto.repo.listRecords', { 21 - params: { repo: identity.did as ActorIdentifier, collection: collection as Nsid, limit, reverse: true, cursor } 21 + params: { repo: identity.did as ActorIdentifier, collection: collection as Nsid, limit, reverse: false, cursor } 22 22 }) 23 23 ); 24 24
+53 -1
src/lib/atproto/repo.svelte.ts
··· 1 1 import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 - import type { ActorIdentifier } from '@atcute/lexicons/syntax'; 2 + import type { ActorIdentifier, Nsid } from '@atcute/lexicons/syntax'; 3 3 import type {} from '@atcute/atproto'; 4 4 import type { CachedRecord, CachedRecordInput } from '$lib/db'; 5 5 import { errorMessage } from '$lib/utils/errors'; ··· 164 164 } 165 165 } 166 166 167 + async openRecordRoute(identity: AccountIdentity, collectionName: string, rkey: string) { 168 + this.reset(); 169 + this.loadedDid = identity.did; 170 + this.selectedCollection = collectionName; 171 + this.isLoadingCollections = true; 172 + this.isLoadingRecords = true; 173 + this.error = null; 174 + 175 + try { 176 + const rpc = createRepoClient(identity); 177 + const [repo, record] = await Promise.all([ 178 + ok(rpc.get('com.atproto.repo.describeRepo', { params: { repo: identity.did as ActorIdentifier } })), 179 + getRepoRecord(identity, collectionName, rkey) 180 + ]); 181 + const collectionNames = repo.collections.some((name) => name === collectionName) 182 + ? repo.collections 183 + : [collectionName, ...repo.collections]; 184 + 185 + this.collections = collectionNames.sort().map((name) => collectionSummaryForName(name, null)); 186 + this.selectedCollection = collectionName; 187 + this.recordPages = listRecordPages({ identity, collection: collectionName, limit: 25 }); 188 + await this.loadNextRecordPage(identity, { throwOnError: false }); 189 + 190 + const summary = summarizeRecord(record, identity.handle); 191 + this.selectedRecord = summary; 192 + 193 + if (!this.records.some((existingRecord) => existingRecord.uri === summary.uri)) { 194 + this.records = [summary, ...this.records]; 195 + } 196 + 197 + this.collections = this.collections.map((collection) => 198 + collection.name === collectionName ? { ...collection, loadedCount: this.records.length } : collection 199 + ); 200 + } catch (unknownError) { 201 + this.error = errorMessage(unknownError, `Could not open ${collectionName}/${rkey}.`); 202 + } finally { 203 + this.isLoadingCollections = false; 204 + this.isLoadingRecords = false; 205 + } 206 + } 207 + 167 208 async loadRecordsFromCache(identity: AccountIdentity, collectionName: string) { 168 209 try { 169 210 const { getDatabase, listCachedRecords } = await import('$lib/db'); ··· 206 247 207 248 function createRepoClient(identity: AccountIdentity) { 208 249 return new Client({ handler: simpleFetchHandler({ service: identity.pds ?? 'https://public.api.bsky.app' }) }); 250 + } 251 + 252 + async function getRepoRecord(identity: AccountIdentity, collectionName: string, rkey: string): Promise<UnknownRecord> { 253 + const rpc = createRepoClient(identity); 254 + const record = await ok( 255 + rpc.get('com.atproto.repo.getRecord', { 256 + params: { repo: identity.did as ActorIdentifier, collection: collectionName as Nsid, rkey } 257 + }) 258 + ); 259 + 260 + return { uri: record.uri, cid: record.cid ?? '', value: record.value }; 209 261 } 210 262 211 263 function preferredCollection(collections: string[]) {
+5
src/lib/atproto/routes.ts
··· 1 + export type RecordRouteParams = { did: string; collection: string; rkey: string }; 2 + 3 + export function recordPath({ did, collection, rkey }: RecordRouteParams) { 4 + return `/records/${did}/${collection}/${encodeURIComponent(rkey)}`; 5 + }
+11
src/lib/components/CollectionBrowser.svelte
··· 1 1 <script lang="ts"> 2 + import { goto } from '$app/navigation'; 2 3 import { onMount } from 'svelte'; 3 4 import { accountSetup } from '$lib/atproto/setup.svelte'; 4 5 import { repoBrowser } from '$lib/atproto/repo.svelte'; 6 + import { recordPath } from '$lib/atproto/routes'; 5 7 import type { CollectionSummary, RepoRecordSummary } from '$lib/atproto/types'; 6 8 import { windowManager } from '$lib/window-manager.svelte'; 7 9 import { SvelteMap } from 'svelte/reactivity'; ··· 44 46 repoBrowser.selectedRecord = record; 45 47 windowManager.setTitle('gedit', `${record.rkey}.json - gedit`, record.icon); 46 48 windowManager.open('gedit'); 49 + 50 + const identity = accountSetup.identity; 51 + if (identity) { 52 + // eslint-disable-next-line svelte/no-navigation-without-resolve 53 + void goto(recordPath({ did: identity.did, collection: record.collection, rkey: record.rkey }), { 54 + keepFocus: true, 55 + noScroll: true 56 + }); 57 + } 47 58 } 48 59 49 60 function groupCollections(collections: CollectionSummary[]) {
+39
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 3 import { dev } from '$app/environment'; 4 + import { page } from '$app/state'; 4 5 import { repoBrowser } from '$lib/atproto/repo.svelte'; 5 6 import { accountSetup } from '$lib/atproto/setup.svelte'; 6 7 import favicon from '$lib/assets/favicon.svg'; ··· 26 27 let bootStep = $state('Loading database'); 27 28 let bootError = $state<string | null>(null); 28 29 let cacheDisabled = $state(false); 30 + let handledRecordRoute = $state<string | null>(null); 29 31 30 32 const windowTitle = $derived( 31 33 accountSetup.isConfigured ? 'AT Protocol Collections - Intrepid Ibex' : 'AT Protocol Account Setup' ··· 85 87 } 86 88 }); 87 89 90 + $effect(() => { 91 + const route = recordRouteFromParams(); 92 + if (bootStatus !== 'ready' || !route) return; 93 + 94 + const routeKey = `${route.did}/${route.collection}/${route.rkey}`; 95 + if (handledRecordRoute === routeKey) return; 96 + 97 + handledRecordRoute = routeKey; 98 + void openRecordRoute(route); 99 + }); 100 + 88 101 onMount(() => { 89 102 void bootDesktop(); 90 103 }); ··· 138 151 cacheDisabled = true; 139 152 bootStatus = 'ready'; 140 153 accountSetup.load(); 154 + } 155 + 156 + async function openRecordRoute(route: { did: string; collection: string; rkey: string }) { 157 + try { 158 + const { hydratePublicIdentity } = await import('$lib/atproto/identity'); 159 + const identity = await hydratePublicIdentity(route.did); 160 + 161 + await repoBrowser.openRecordRoute(identity, route.collection, route.rkey); 162 + accountSetup.save(identity); 163 + 164 + windowManager.restore('main'); 165 + if (repoBrowser.selectedRecord) { 166 + windowManager.open('gedit'); 167 + } 168 + } catch (unknownError) { 169 + repoBrowser.error = errorMessage(unknownError, 'Could not open that record route.'); 170 + } 171 + } 172 + 173 + function recordRouteFromParams() { 174 + if (page.route.id !== '/records/[did]/[collection]/[rkey]') return null; 175 + 176 + const { did, collection, rkey } = page.params; 177 + if (!did || !collection || !rkey) return null; 178 + 179 + return { did, collection, rkey }; 141 180 } 142 181 143 182 async function waitForMinimumBootTime(startedAt: number) {
+5
src/routes/records/[did]/[collection]/[rkey]/+page.svelte
··· 1 + <script lang="ts"> 2 + import CollectionBrowser from '$lib/components/CollectionBrowser.svelte'; 3 + </script> 4 + 5 + <CollectionBrowser />
+1
src/routes/records/[did]/[collection]/[rkey]/+page.ts
··· 1 + export const prerender = false;
+1
static/_redirects
··· 1 + /* /index.html 200
+1 -1
svelte.config.js
··· 6 6 extensions: ['.svelte', '.svx', '.md'], 7 7 preprocess: [mdsvex({ extensions: ['.svx', '.md'] })], 8 8 compilerOptions: { runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true) }, 9 - kit: { adapter: adapter() } 9 + kit: { adapter: adapter({ fallback: 'index.html' }) } 10 10 }; 11 11 12 12 export default config;