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: cursor based pagination

* NS based grouping

Owais Jamil (Jun 9, 2026, 1:30 PM -0500) 08ebc67b bdeafdd6

+242 -43
+5
CHANGELOG.md
··· 17 17 failures can fall back to cached collections/records. 18 18 - Full-text cache search in the Nautilus-style collection browser. 19 19 - Atmosphere app logo mapping for known AT Protocol collection namespaces. 20 + - Cursor-based record pagination with a generator-backed “Load more records” flow. 21 + 22 + #### Changed 23 + 24 + - Grouped Nautilus collections by top-level namespace folders. 20 25 21 26 ### 2026-06-08 22 27
+3 -3
package.json
··· 10 10 "prepare": "svelte-kit sync || echo ''", 11 11 "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 12 12 "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 13 - "lint": "prettier --check . && eslint .", 13 + "lint": "prettier --write . && eslint . --fix", 14 14 "format": "prettier --write .", 15 - "test:unit": "vitest", 16 - "test": "npm run test:unit -- --run" 15 + "test": "vitest", 16 + "test:unit": "npm run test -- --run" 17 17 }, 18 18 "devDependencies": { 19 19 "@eslint/js": "^10.0.1",
+28
src/lib/atproto/pagination.ts
··· 1 + import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 + import type { ActorIdentifier, Nsid } from '@atcute/lexicons/syntax'; 3 + import type {} from '@atcute/atproto'; 4 + import type { AccountIdentity, UnknownRecord } from './types'; 5 + 6 + export type RecordPage = { records: UnknownRecord[]; cursor: string | null }; 7 + 8 + export type RecordPageRequest = { identity: AccountIdentity; collection: string; limit?: number }; 9 + 10 + export async function* listRecordPages({ 11 + identity, 12 + collection, 13 + limit = 25 14 + }: RecordPageRequest): AsyncGenerator<RecordPage> { 15 + const rpc = new Client({ handler: simpleFetchHandler({ service: identity.pds ?? 'https://public.api.bsky.app' }) }); 16 + let cursor: string | undefined; 17 + 18 + do { 19 + const response = await ok( 20 + rpc.get('com.atproto.repo.listRecords', { 21 + params: { repo: identity.did as ActorIdentifier, collection: collection as Nsid, limit, reverse: true, cursor } 22 + }) 23 + ); 24 + 25 + cursor = response.cursor; 26 + yield { records: response.records, cursor: cursor ?? null }; 27 + } while (cursor); 28 + }
+49 -19
src/lib/atproto/repo.svelte.ts
··· 1 1 import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 - import type { ActorIdentifier, Nsid } from '@atcute/lexicons/syntax'; 2 + import type { ActorIdentifier } from '@atcute/lexicons/syntax'; 3 3 import type {} from '@atcute/atproto'; 4 4 import { 5 5 cacheFetchedRecords, ··· 13 13 } from '$lib/db'; 14 14 import { errorMessage } from '$lib/utils/errors'; 15 15 import { appLabelForCollection, collectionIconMatch } from './collection-icons'; 16 + import { listRecordPages, type RecordPage } from './pagination'; 16 17 import { 17 18 isRecordValue, 18 19 type AccountIdentity, ··· 29 30 records = $state<RepoRecordSummary[]>([]); 30 31 isLoadingCollections = $state(false); 31 32 isLoadingRecords = $state(false); 33 + isLoadingMoreRecords = $state(false); 34 + canLoadMoreRecords = $state(false); 32 35 isSearching = $state(false); 33 36 searchQuery = $state(''); 34 37 error = $state<string | null>(null); 35 38 loadedDid = $state<string | null>(null); 36 39 selectedRecord = $state<RepoRecordSummary | null>(null); 40 + private recordPages: AsyncGenerator<RecordPage> | null = null; 37 41 38 42 get selectedSummary() { 39 43 return this.collections.find((collection) => collection.name === this.selectedCollection) ?? null; ··· 75 79 this.selectedCollection = collectionName; 76 80 this.searchQuery = ''; 77 81 this.isLoadingRecords = true; 82 + this.canLoadMoreRecords = false; 78 83 this.error = null; 79 84 80 85 try { 81 - const rpc = createRepoClient(identity); 82 - const response = await ok( 83 - rpc.get('com.atproto.repo.listRecords', { 84 - params: { 85 - repo: identity.did as ActorIdentifier, 86 - collection: collectionName as Nsid, 87 - limit: 25, 88 - reverse: true 89 - } 90 - }) 91 - ); 92 - 93 - this.records = response.records.map((record) => summarizeRecord(record, identity.handle)); 94 - this.collections = this.collections.map((collection) => 95 - collection.name === collectionName ? { ...collection, loadedCount: response.records.length } : collection 96 - ); 97 - void cacheLiveRecords(identity, collectionName, response.records); 86 + this.records = []; 87 + this.recordPages = listRecordPages({ identity, collection: collectionName, limit: 25 }); 88 + await this.loadNextRecordPage(identity, { throwOnError: true }); 98 89 } catch (unknownError) { 99 90 const loadedFromCache = await this.loadRecordsFromCache(identity, collectionName); 100 91 this.error = loadedFromCache ··· 105 96 } 106 97 } 107 98 99 + async loadNextRecordPage(identity: AccountIdentity, options: { throwOnError?: boolean } = {}) { 100 + if (!this.recordPages || !this.selectedCollection || this.isLoadingMoreRecords) return; 101 + 102 + this.isLoadingMoreRecords = true; 103 + this.error = null; 104 + 105 + try { 106 + const page = await this.recordPages.next(); 107 + this.canLoadMoreRecords = !page.done && page.value.cursor !== null; 108 + 109 + if (page.done) return; 110 + 111 + const nextRecords = page.value.records.map((record) => summarizeRecord(record, identity.handle)); 112 + this.records = [...this.records, ...nextRecords]; 113 + this.collections = this.collections.map((collection) => 114 + collection.name === this.selectedCollection ? { ...collection, loadedCount: this.records.length } : collection 115 + ); 116 + void cacheLiveRecords(identity, this.selectedCollection, page.value.records, page.value.cursor); 117 + } catch (unknownError) { 118 + this.canLoadMoreRecords = false; 119 + this.error = errorMessage(unknownError, `Could not load more records for ${this.selectedCollection}.`); 120 + if (options.throwOnError) throw unknownError; 121 + } finally { 122 + this.isLoadingMoreRecords = false; 123 + } 124 + } 125 + 108 126 async loadCollectionsFromCache(identity: AccountIdentity) { 109 127 try { 110 128 const db = await getDatabase(); ··· 138 156 } 139 157 140 158 this.isSearching = true; 159 + this.canLoadMoreRecords = false; 141 160 this.error = null; 142 161 143 162 try { ··· 162 181 163 182 if (records.length === 0) return false; 164 183 184 + this.recordPages = null; 185 + this.canLoadMoreRecords = false; 165 186 this.records = records.map((record) => summarizeCachedRecord(record, identity.handle)); 166 187 this.collections = this.collections.map((collection) => 167 188 collection.name === collectionName ? { ...collection, loadedCount: records.length } : collection ··· 180 201 this.selectedRecord = null; 181 202 this.isLoadingCollections = false; 182 203 this.isLoadingRecords = false; 204 + this.isLoadingMoreRecords = false; 205 + this.canLoadMoreRecords = false; 183 206 this.isSearching = false; 184 207 this.searchQuery = ''; 185 208 this.error = null; 186 209 this.loadedDid = null; 210 + this.recordPages = null; 187 211 } 188 212 } 189 213 ··· 238 262 return summarizeRecord({ uri: record.uri, cid: record.cid, value: record.value }, handle); 239 263 } 240 264 241 - async function cacheLiveRecords(identity: AccountIdentity, collectionName: string, records: readonly UnknownRecord[]) { 265 + async function cacheLiveRecords( 266 + identity: AccountIdentity, 267 + collectionName: string, 268 + records: readonly UnknownRecord[], 269 + cursor: string | null 270 + ) { 242 271 try { 243 272 const db = await getDatabase(); 244 273 await cacheFetchedRecords( ··· 249 278 accountDid: identity.did, 250 279 repoDid: identity.did, 251 280 collection: collectionName, 281 + cursor, 252 282 lastSyncedAt: new Date().toISOString(), 253 283 lastError: null 254 284 });
+157 -21
src/lib/components/CollectionBrowser.svelte
··· 2 2 import { onMount } from 'svelte'; 3 3 import { accountSetup } from '$lib/atproto/setup.svelte'; 4 4 import { repoBrowser } from '$lib/atproto/repo.svelte'; 5 - import type { RepoRecordSummary } from '$lib/atproto/types'; 5 + import type { CollectionSummary, RepoRecordSummary } from '$lib/atproto/types'; 6 6 import { windowManager } from '$lib/window-manager.svelte'; 7 + import { SvelteMap } from 'svelte/reactivity'; 7 8 8 9 let searchQuery = $state(''); 10 + 11 + const collectionGroups = $derived.by(() => groupCollections(repoBrowser.collections)); 9 12 10 13 onMount(() => { 11 14 const identity = accountSetup.identity; ··· 42 45 windowManager.setTitle('gedit', `${record.rkey}.json - gedit`, record.icon); 43 46 windowManager.open('gedit'); 44 47 } 48 + 49 + function groupCollections(collections: CollectionSummary[]) { 50 + const groups = new SvelteMap< 51 + string, 52 + { namespace: string; label: string; icon: string; collections: CollectionSummary[] } 53 + >(); 54 + 55 + for (const collection of collections) { 56 + const namespace = namespaceForCollection(collection.name); 57 + const group = groups.get(namespace) ?? { 58 + namespace, 59 + label: collection.appLabel ?? namespace, 60 + icon: collection.icon, 61 + collections: [] 62 + }; 63 + 64 + if (!group.label || group.label === namespace) group.label = collection.appLabel ?? namespace; 65 + if (!group.icon) group.icon = collection.icon; 66 + group.collections.push(collection); 67 + groups.set(namespace, group); 68 + } 69 + 70 + return [...groups.values()].sort((a, b) => a.namespace.localeCompare(b.namespace)); 71 + } 72 + 73 + function namespaceForCollection(collection: string) { 74 + const parts = collection.split('.'); 75 + return parts.length >= 2 ? `${parts[0]}.${parts[1]}` : collection; 76 + } 45 77 </script> 46 78 47 79 <div class="collection-browser"> ··· 51 83 <p>{accountSetup.identity?.handle ?? 'at:// repo folders'}</p> 52 84 </header> 53 85 54 - <ul> 86 + <div class="collection-tree" role="tree" aria-label="Repository namespaces"> 55 87 {#if repoBrowser.isLoadingCollections} 56 - <li class="empty-row">Loading collections…</li> 88 + <p class="empty-row">Loading collections…</p> 57 89 {:else} 58 - {#each repoBrowser.collections as collection (collection.name)} 59 - <li> 60 - <button 61 - class:active={collection.name === repoBrowser.selectedCollection} 62 - type="button" 63 - onclick={() => selectCollection(collection.name)}> 64 - <img src={collection.icon} alt="" width="24" height="24" /> 65 - <span>{collection.name}</span> 66 - <small>{collection.loadedCount ?? 'repo'}</small> 67 - </button> 68 - </li> 90 + {#each collectionGroups as group (group.namespace)} 91 + <details class="namespace-group" open> 92 + <summary aria-label={`${group.namespace} namespace`}> 93 + <span class="namespace-leading" aria-hidden="true"> 94 + <span class="namespace-caret">▸</span> 95 + <img src={group.icon} alt="" width="22" height="22" /> 96 + </span> 97 + <span class="namespace-name">{group.namespace}.* ({group.collections.length})</span> 98 + </summary> 99 + 100 + <ul role="group"> 101 + {#each group.collections as collection (collection.name)} 102 + <li> 103 + <button 104 + role="treeitem" 105 + aria-selected={collection.name === repoBrowser.selectedCollection} 106 + class:active={collection.name === repoBrowser.selectedCollection} 107 + type="button" 108 + onclick={() => selectCollection(collection.name)}> 109 + <img src={collection.icon} alt="" width="22" height="22" /> 110 + <span>{collection.name}</span> 111 + <small>{collection.loadedCount ?? 'repo'}</small> 112 + </button> 113 + </li> 114 + {/each} 115 + </ul> 116 + </details> 69 117 {/each} 70 118 {/if} 71 - </ul> 119 + </div> 72 120 </aside> 73 121 74 122 <section class="record-pane" aria-label="Collection records"> ··· 94 142 {/if} 95 143 </p> 96 144 </div> 97 - <form class="search-box" role="search" onsubmit={(event) => { event.preventDefault(); searchRecords(); }}> 145 + <form 146 + class="search-box" 147 + role="search" 148 + onsubmit={(event) => { 149 + event.preventDefault(); 150 + searchRecords(); 151 + }}> 98 152 <label for="record-search">Search cache</label> 99 153 <div> 100 154 <input ··· 123 177 <p class="message">{repoBrowser.isSearching ? 'Searching cached records…' : 'Loading records…'}</p> 124 178 {:else if repoBrowser.records.length === 0} 125 179 <p class="message"> 126 - {repoBrowser.searchQuery ? 'No cached records matched that search.' : 'No public records found for this collection.'} 180 + {repoBrowser.searchQuery 181 + ? 'No cached records matched that search.' 182 + : 'No public records found for this collection.'} 127 183 </p> 128 184 {:else} 129 185 {#each repoBrowser.records as record (record.uri)} ··· 137 193 <time>{record.modified}</time> 138 194 </button> 139 195 {/each} 196 + 197 + {#if repoBrowser.canLoadMoreRecords && !repoBrowser.searchQuery} 198 + <button 199 + class="load-more-records" 200 + type="button" 201 + disabled={repoBrowser.isLoadingMoreRecords} 202 + onclick={() => accountSetup.identity && repoBrowser.loadNextRecordPage(accountSetup.identity)}> 203 + {repoBrowser.isLoadingMoreRecords ? 'Loading more records…' : 'Load more records'} 204 + </button> 205 + {/if} 140 206 {/if} 141 207 </div> 142 208 </section> ··· 180 246 font-size: var(--text-1); 181 247 } 182 248 183 - .sidebar ul { 249 + .collection-tree { 184 250 overflow: auto; 185 251 padding: var(--space-2); 186 252 } 187 253 254 + .namespace-group + .namespace-group { 255 + margin-top: var(--space-1); 256 + } 257 + 258 + .namespace-group summary, 188 259 .sidebar button { 189 260 display: grid; 190 - grid-template-columns: 24px minmax(0, 1fr) auto; 261 + grid-template-columns: 22px minmax(0, 1fr) auto; 191 262 align-items: center; 192 263 gap: var(--space-2); 193 264 width: 100%; 194 - padding: var(--space-2); 195 265 border: 1px solid transparent; 196 266 border-radius: var(--radius-2); 197 267 font-size: var(--text-1); ··· 199 269 cursor: default; 200 270 } 201 271 272 + .namespace-group summary { 273 + grid-template-columns: 3.4rem minmax(0, 1fr); 274 + padding: var(--space-2); 275 + color: #49321f; 276 + font-weight: 700; 277 + list-style: none; 278 + background: rgb(255 255 255 / 0.22); 279 + border-color: rgb(145 111 71 / 0.35); 280 + } 281 + 282 + .namespace-group summary::-webkit-details-marker { 283 + display: none; 284 + } 285 + 286 + .namespace-leading { 287 + display: flex; 288 + align-items: center; 289 + gap: var(--space-1); 290 + min-width: 0; 291 + } 292 + 293 + .namespace-caret { 294 + width: 0.75rem; 295 + color: #6d4b2d; 296 + font-size: var(--text-0); 297 + line-height: 1; 298 + } 299 + 300 + .namespace-group[open] .namespace-caret { 301 + transform: rotate(90deg); 302 + } 303 + 304 + .namespace-name { 305 + justify-self: end; 306 + } 307 + 308 + .namespace-group ul { 309 + display: grid; 310 + gap: 1px; 311 + padding: var(--space-1) 0 var(--space-1) var(--space-3); 312 + } 313 + 314 + .sidebar button { 315 + padding: var(--space-1) var(--space-2); 316 + } 317 + 202 318 .sidebar button.active, 203 - .sidebar button:hover { 319 + .sidebar button:hover, 320 + .namespace-group summary:hover { 204 321 color: white; 205 322 background: linear-gradient(#e48631, #b65312); 206 323 border-color: #f4b46b #8d3c0b #713009 #f1a35e; ··· 352 469 353 470 .record-row:hover { 354 471 background: #f1d09d; 472 + } 473 + 474 + .load-more-records { 475 + display: block; 476 + width: max-content; 477 + margin: var(--space-3) auto 0; 478 + padding: var(--space-2) var(--space-4); 479 + color: #2c180d; 480 + background: linear-gradient(#fff8e8, #d3b17d); 481 + border: 1px solid #9d7a4b; 482 + border-radius: var(--radius-2); 483 + box-shadow: var(--shadow-raised); 484 + font: inherit; 485 + font-size: var(--text-1); 486 + font-weight: 700; 487 + } 488 + 489 + .load-more-records:disabled { 490 + opacity: 0.62; 355 491 } 356 492 357 493 .record-row img,