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.

refactor: types & type guard module for atmosphere

* add CHANGELOG

Owais Jamil (Jun 9, 2026, 1:01 PM -0500) 77105407 c1f7bd4b

+216 -36
+38
CHANGELOG.md
··· 1 + # Changelog 2 + 3 + All notable changes to this project will be documented in this file. 4 + 5 + The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 + and this project targets [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 + 8 + ## [Unreleased] 9 + 10 + Target release: `v1.0.0`. 11 + 12 + ### 2026-06-09 13 + 14 + #### Added 15 + 16 + - PGlite-backed local cache/search foundation with WASM build support. 17 + - Transactional schema migrations for accounts, cached records, collection 18 + sync state, and record search. 19 + - Ubuntu-purple boot splash that initializes PGlite, runs migrations, and offers 20 + recovery actions. 21 + - Cache-aware AT Protocol browsing: live record fetches populate PGlite and offline 22 + failures can fall back to cached collections/records. 23 + - Shared `errorMessage()` utility for consistent unknown-error messages. 24 + 25 + ### 2026-06-08 26 + 27 + #### Added 28 + 29 + - Ubuntu 8.10 / GNOME 2-inspired desktop shell, panel, wallpaper, windows, and Humanity 30 + icon styling. 31 + - First-run AT Protocol account setup flow with handle resolution and persisted account 32 + identity. 33 + - Nautilus-style collection browser backed by live AT Protocol repository collection and 34 + record loading. 35 + - gedit-style JSON record viewer opened from selected records. 36 + - Window management basics including movable, resizable, minimizable, maximizable, and 37 + closable windows. 38 + - About Computer dialog, desktop icons, sticky note, and Tangled project links.
+2 -12
src/lib/atproto/identity.ts
··· 3 3 import type { Handle } from '@atcute/lexicons/syntax'; 4 4 import type {} from '@atcute/atproto'; 5 5 import type {} from '@atcute/bluesky'; 6 + import type { AccountIdentity, DidDocument } from './types'; 6 7 7 - export type AccountIdentity = { 8 - handle: string; 9 - did: string; 10 - pds: string | null; 11 - displayName: string | null; 12 - avatar: string | null; 13 - description: string | null; 14 - }; 8 + export type { AccountIdentity } from './types'; 15 9 16 10 export const defaultIdentity = { handle: 'desertthunder.dev', did: 'did:plc:xg2vq45muivyy3xwatcehspu' }; 17 11 18 12 const publicApi = new Client({ handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) }); 19 - 20 - type DidDocument = { 21 - service?: Array<{ id?: string; type?: string; serviceEndpoint?: string | string[] | Record<string, unknown> }>; 22 - }; 23 13 24 14 export async function resolveAccount(handle: string): Promise<AccountIdentity> { 25 15 const normalizedHandle = handle.trim().replace(/^@/, '').toLowerCase();
+117 -21
src/lib/atproto/repo.svelte.ts
··· 1 1 import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 2 import type { ActorIdentifier, Nsid } from '@atcute/lexicons/syntax'; 3 3 import type {} from '@atcute/atproto'; 4 + import { 5 + cacheFetchedRecords, 6 + getDatabase, 7 + listCachedCollections, 8 + listCachedRecords, 9 + updateCollectionSyncState, 10 + type CachedRecord, 11 + type CachedRecordInput 12 + } from '$lib/db'; 4 13 import { errorMessage } from '$lib/utils/errors'; 5 - import type { AccountIdentity } from './identity'; 14 + import { 15 + isRecordValue, 16 + type AccountIdentity, 17 + type CollectionSummary, 18 + type RepoRecordSummary, 19 + type UnknownRecord 20 + } from './types'; 6 21 7 - export type CollectionSummary = { name: string; icon: string; loadedCount: number | null }; 8 - 9 - export type RepoRecordSummary = { 10 - uri: string; 11 - cid: string; 12 - title: string; 13 - body: string; 14 - author: string; 15 - modified: string; 16 - collection: string; 17 - rkey: string; 18 - json: string; 19 - }; 22 + export type { CollectionSummary, RepoRecordSummary } from './types'; 20 23 21 24 class RepoBrowserState { 22 25 collections = $state<CollectionSummary[]>([]); ··· 57 60 await this.selectCollection(identity, this.selectedCollection); 58 61 } 59 62 } catch (unknownError) { 60 - this.error = errorMessage(unknownError, 'Could not load repository collections.'); 63 + const loadedFromCache = await this.loadCollectionsFromCache(identity); 64 + this.error = loadedFromCache 65 + ? `Showing cached collections. ${errorMessage(unknownError, 'Could not load repository collections.')}` 66 + : errorMessage(unknownError, 'Could not load repository collections.'); 61 67 } finally { 62 68 this.isLoadingCollections = false; 63 69 } ··· 85 91 this.collections = this.collections.map((collection) => 86 92 collection.name === collectionName ? { ...collection, loadedCount: response.records.length } : collection 87 93 ); 94 + void cacheLiveRecords(identity, collectionName, response.records); 88 95 } catch (unknownError) { 89 - this.records = []; 90 - this.error = errorMessage(unknownError, `Could not load records for ${collectionName}.`); 96 + const loadedFromCache = await this.loadRecordsFromCache(identity, collectionName); 97 + this.error = loadedFromCache 98 + ? `Showing cached records. ${errorMessage(unknownError, `Could not load records for ${collectionName}.`)}` 99 + : errorMessage(unknownError, `Could not load records for ${collectionName}.`); 91 100 } finally { 92 101 this.isLoadingRecords = false; 93 102 } 94 103 } 95 104 105 + async loadCollectionsFromCache(identity: AccountIdentity) { 106 + try { 107 + const db = await getDatabase(); 108 + const collections = await listCachedCollections(db, identity.did); 109 + 110 + if (collections.length === 0) return false; 111 + 112 + this.collections = collections.map((collection) => ({ 113 + name: collection.name, 114 + icon: iconForCollection(collection.name), 115 + loadedCount: collection.loadedCount 116 + })); 117 + this.selectedCollection = preferredCollection(this.collections.map((collection) => collection.name)); 118 + 119 + if (this.selectedCollection) { 120 + await this.loadRecordsFromCache(identity, this.selectedCollection); 121 + } 122 + 123 + return true; 124 + } catch (cacheError) { 125 + console.warn('Could not read cached collections.', cacheError); 126 + return false; 127 + } 128 + } 129 + 130 + async loadRecordsFromCache(identity: AccountIdentity, collectionName: string) { 131 + try { 132 + const db = await getDatabase(); 133 + const records = await listCachedRecords(db, { repoDid: identity.did, collection: collectionName, limit: 25 }); 134 + 135 + if (records.length === 0) return false; 136 + 137 + this.records = records.map((record) => summarizeCachedRecord(record, identity.handle)); 138 + this.collections = this.collections.map((collection) => 139 + collection.name === collectionName ? { ...collection, loadedCount: records.length } : collection 140 + ); 141 + return true; 142 + } catch (cacheError) { 143 + console.warn('Could not read cached records.', cacheError); 144 + return false; 145 + } 146 + } 147 + 96 148 reset() { 97 149 this.collections = []; 98 150 this.selectedCollection = null; ··· 123 175 return '/icons/humanity/mimes/text-x-generic.svg'; 124 176 } 125 177 126 - type UnknownRecord = { uri: string; cid: string; value: unknown }; 127 - 128 178 function summarizeRecord(record: UnknownRecord, handle: string): RepoRecordSummary { 129 179 const value = isRecordValue(record.value) ? record.value : {}; 130 180 const text = stringifyField(value.text) ?? stringifyField(value.name) ?? stringifyField(value.displayName); ··· 146 196 }; 147 197 } 148 198 149 - function isRecordValue(value: unknown): value is Record<string, unknown> { 150 - return typeof value === 'object' && value !== null; 199 + function summarizeCachedRecord(record: CachedRecord, handle: string): RepoRecordSummary { 200 + return summarizeRecord({ uri: record.uri, cid: record.cid, value: record.value }, handle); 201 + } 202 + 203 + async function cacheLiveRecords(identity: AccountIdentity, collectionName: string, records: readonly UnknownRecord[]) { 204 + try { 205 + const db = await getDatabase(); 206 + await cacheFetchedRecords( 207 + db, 208 + records.map((record) => toCachedRecordInput(identity, collectionName, record)) 209 + ); 210 + await updateCollectionSyncState(db, { 211 + accountDid: identity.did, 212 + repoDid: identity.did, 213 + collection: collectionName, 214 + lastSyncedAt: new Date().toISOString(), 215 + lastError: null 216 + }); 217 + } catch (cacheError) { 218 + console.warn('Could not write records to local cache.', cacheError); 219 + } 220 + } 221 + 222 + function toCachedRecordInput( 223 + identity: AccountIdentity, 224 + collectionName: string, 225 + record: UnknownRecord 226 + ): CachedRecordInput { 227 + const value = isRecordValue(record.value) ? record.value : {}; 228 + const text = stringifyField(value.text) ?? stringifyField(value.name) ?? stringifyField(value.displayName) ?? ''; 229 + const type = stringifyField(value.$type) ?? collectionName; 230 + const createdAt = stringifyField(value.createdAt); 231 + const indexedAt = stringifyField(value.indexedAt); 232 + const updatedAt = stringifyField(value.updatedAt); 233 + 234 + return { 235 + accountDid: identity.did, 236 + repoDid: identity.did, 237 + collection: collectionName, 238 + rkey: recordKeyFromUri(record.uri), 239 + uri: record.uri, 240 + cid: record.cid, 241 + value: record.value, 242 + indexedText: [type, text, record.uri].filter(Boolean).join('\n'), 243 + createdAt, 244 + indexedAt, 245 + updatedAt 246 + }; 151 247 } 152 248 153 249 function stringifyField(value: unknown) {
+2 -1
src/lib/atproto/setup.svelte.ts
··· 1 1 import { browser } from '$app/environment'; 2 - import { defaultIdentity, type AccountIdentity } from './identity'; 2 + import { defaultIdentity } from './identity'; 3 + import type { AccountIdentity } from './types'; 3 4 4 5 const storageKey = 'intrepid-ibex:account-identity'; 5 6
+32
src/lib/atproto/types.ts
··· 1 + export type AccountIdentity = { 2 + handle: string; 3 + did: string; 4 + pds: string | null; 5 + displayName: string | null; 6 + avatar: string | null; 7 + description: string | null; 8 + }; 9 + 10 + export type DidDocument = { 11 + service?: Array<{ id?: string; type?: string; serviceEndpoint?: string | string[] | Record<string, unknown> }>; 12 + }; 13 + 14 + export type CollectionSummary = { name: string; icon: string; loadedCount: number | null }; 15 + 16 + export type RepoRecordSummary = { 17 + uri: string; 18 + cid: string; 19 + title: string; 20 + body: string; 21 + author: string; 22 + modified: string; 23 + collection: string; 24 + rkey: string; 25 + json: string; 26 + }; 27 + 28 + export type UnknownRecord = { uri: string; cid: string; value: unknown }; 29 + 30 + export function isRecordValue(value: unknown): value is Record<string, unknown> { 31 + return typeof value === 'object' && value !== null; 32 + }
+2 -1
src/lib/components/CollectionBrowser.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 3 import { accountSetup } from '$lib/atproto/setup.svelte'; 4 - import { repoBrowser, type RepoRecordSummary } from '$lib/atproto/repo.svelte'; 4 + import { repoBrowser } from '$lib/atproto/repo.svelte'; 5 + import type { RepoRecordSummary } from '$lib/atproto/types'; 5 6 import { windowManager } from '$lib/window-manager.svelte'; 6 7 7 8 onMount(() => {
+2 -1
src/lib/components/SetupDialog.svelte
··· 1 1 <script lang="ts"> 2 - import { resolveAccount, type AccountIdentity } from '$lib/atproto/identity'; 2 + import { resolveAccount } from '$lib/atproto/identity'; 3 + import type { AccountIdentity } from '$lib/atproto/types'; 3 4 import { accountSetup, setupDefaults } from '$lib/atproto/setup.svelte'; 4 5 import { errorMessage } from '$lib/utils/errors'; 5 6
+21
src/lib/db/repositories/records.ts
··· 19 19 20 20 export type ListCachedRecordsOptions = { repoDid: string; collection?: string; limit?: number; offset?: number }; 21 21 22 + export type CachedCollectionSummary = { name: string; loadedCount: number; lastStoredAt: string | null }; 23 + 22 24 type CachedRecordRow = { 23 25 account_did: string; 24 26 repo_did: string; ··· 113 115 ); 114 116 115 117 return result.rows.map(rowToCachedRecord); 118 + } 119 + 120 + export async function listCachedCollections(db: DbClient, repoDid: string): Promise<CachedCollectionSummary[]> { 121 + const result = await db.query<{ collection: string; loaded_count: number; last_stored_at: string | null }>( 122 + ` 123 + select collection, count(*)::int as loaded_count, max(stored_at) as last_stored_at 124 + from ${TABLES.cachedRecords} 125 + where repo_did = $1 and sync_status <> 'deleted' 126 + group by collection 127 + order by collection asc 128 + `, 129 + [repoDid] 130 + ); 131 + 132 + return result.rows.map((row) => ({ 133 + name: row.collection, 134 + loadedCount: row.loaded_count, 135 + lastStoredAt: row.last_stored_at 136 + })); 116 137 } 117 138 118 139 export async function getCachedRecordByUri(db: DbClient, uri: string): Promise<CachedRecord | null> {