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: repo session and blob metadata

Owais Jamil (Jun 17, 2026, 12:20 PM -0500) dba82c81 06354648

+388 -94
+5
.changeset/silent-readers-smash.md
··· 1 + --- 2 + 'intrepid-ibex': minor 3 + --- 4 + 5 + changed identity resolution to url state based for deep linking
+48 -16
src/lib/atproto/blobs.svelte.ts
··· 3 3 import type {} from '@atcute/atproto'; 4 4 import { errorMessage } from '$lib/utils/errors'; 5 5 import type { AccountIdentity, BlobReference, RepoBlobSummary } from './types'; 6 + import { SvelteURL } from 'svelte/reactivity'; 6 7 7 8 class RepoBlobState { 8 9 blobs = $state<RepoBlobSummary[]>([]); ··· 81 82 82 83 openMedia(identity: AccountIdentity, media: BlobReference) { 83 84 this.loadedDid = identity.did; 84 - const summary = blobSummary(identity, media.cid, media.sourceUri, media.sourceIcon); 85 + const summary = blobSummary( 86 + identity, 87 + media.cid, 88 + media.sourceUri, 89 + media.sourceIcon, 90 + media.mimeType, 91 + media.size, 92 + media.path 93 + ); 85 94 86 95 if (!this.blobs.some((blob) => blob.cid === media.cid)) { 87 96 this.blobs = [summary, ...this.blobs]; ··· 121 130 } 122 131 } 123 132 124 - function isBlobObject(value: object): value is { ref: { $link: string } } { 133 + function isBlobObject(value: object): value is { ref: { $link: string }; mimeType?: unknown; size?: unknown } { 125 134 if (!('ref' in value)) return false; 126 135 const { ref } = value; 127 136 return ( ··· 142 151 identity: AccountIdentity, 143 152 cid: string, 144 153 sourceUri: string | null, 145 - sourceIcon?: string 154 + sourceIcon?: string, 155 + mimeType: string | null = null, 156 + size: number | null = null, 157 + path: string | null = null 146 158 ): RepoBlobSummary { 147 - return { cid, sourceUri, sourceIcon, rawUrl: rawBlobUrl(identity, cid) }; 159 + return { cid, sourceUri, sourceIcon, mimeType, size, path, rawUrl: rawBlobUrl(identity, cid) }; 148 160 } 149 161 150 - function rawBlobUrl(identity: AccountIdentity, cid: string) { 162 + export function rawBlobUrl(identity: AccountIdentity, cid: string) { 151 163 const service = identity.pds ?? 'https://public.api.bsky.app'; 152 - const url = new URL('/xrpc/com.atproto.sync.getBlob', service); 164 + const url = new SvelteURL('/xrpc/com.atproto.sync.getBlob', service); 153 165 url.searchParams.set('did', identity.did); 154 166 url.searchParams.set('cid', cid); 155 167 return url.toString(); 156 168 } 157 169 158 170 export function firstBlobReference(value: unknown, sourceUri: string | null): BlobReference | null { 159 - if (typeof value !== 'object' || value === null) return null; 171 + return blobReferences(value, sourceUri)[0] ?? null; 172 + } 173 + 174 + export function blobReferences(value: unknown, sourceUri: string | null): BlobReference[] { 175 + return collectBlobReferences(value, sourceUri, []); 176 + } 177 + 178 + export function isRenderableBlob(reference: BlobReference) { 179 + return Boolean(reference.mimeType?.startsWith('image/') || reference.mimeType?.startsWith('video/')); 180 + } 181 + 182 + function collectBlobReferences(value: unknown, sourceUri: string | null, path: string[]): BlobReference[] { 183 + if (typeof value !== 'object' || value === null) return []; 160 184 161 185 if (isBlobObject(value)) { 162 - return { cid: value.ref.$link, sourceUri }; 186 + return [ 187 + { 188 + cid: value.ref.$link, 189 + sourceUri, 190 + mimeType: typeof value.mimeType === 'string' ? value.mimeType : null, 191 + size: typeof value.size === 'number' ? value.size : null, 192 + path: path.length > 0 ? path.join('.') : 'value' 193 + } 194 + ]; 163 195 } 164 196 165 197 if (Array.isArray(value)) { 166 - for (const item of value) { 167 - const found = firstBlobReference(item, sourceUri); 168 - if (found) return found; 198 + const references: BlobReference[] = []; 199 + for (const [index, item] of value.entries()) { 200 + references.push(...collectBlobReferences(item, sourceUri, [...path, `[${index}]`])); 169 201 } 170 - return null; 202 + return references; 171 203 } 172 204 173 - for (const child of Object.values(value)) { 174 - const found = firstBlobReference(child, sourceUri); 175 - if (found) return found; 205 + const references: BlobReference[] = []; 206 + for (const [key, child] of Object.entries(value)) { 207 + references.push(...collectBlobReferences(child, sourceUri, [...path, key])); 176 208 } 177 209 178 - return null; 210 + return references; 179 211 } 180 212 181 213 export const repoBlobs = new RepoBlobState();
+49 -2
src/lib/atproto/blobs.test.ts
··· 1 1 import { describe, expect, it } from 'vitest'; 2 - import { firstBlobReference } from './blobs.svelte'; 2 + import { blobReferences, firstBlobReference, isRenderableBlob } from './blobs.svelte'; 3 3 4 4 describe('ATProto blob helpers', () => { 5 5 it('finds the first embedded blob CID in a post image record', () => { ··· 16 16 'at://did:plc:abc123/app.bsky.feed.post/post1' 17 17 ); 18 18 19 - expect(blob).toEqual({ cid: 'bafkreigoodimage', sourceUri: 'at://did:plc:abc123/app.bsky.feed.post/post1' }); 19 + expect(blob).toEqual({ 20 + cid: 'bafkreigoodimage', 21 + sourceUri: 'at://did:plc:abc123/app.bsky.feed.post/post1', 22 + mimeType: 'image/png', 23 + size: 1234, 24 + path: 'embed.images.[0].image' 25 + }); 20 26 }); 21 27 22 28 it('returns null when a record has no blob ref', () => { 23 29 expect(firstBlobReference({ text: 'plain record' }, 'at://did:plc:abc123/app.bsky.feed.post/post2')).toBeNull(); 30 + }); 31 + 32 + it('collects multiple blob references with MIME metadata', () => { 33 + const references = blobReferences( 34 + { 35 + $type: 'standard.site.document', 36 + cover: { ref: { $link: 'bafkcover' }, mimeType: 'image/jpeg', size: 256 }, 37 + body: { ref: { $link: 'bafkbody' }, mimeType: 'text/markdown', size: 1024 } 38 + }, 39 + 'at://did:plc:abc123/standard.site.document/home' 40 + ); 41 + 42 + expect(references).toEqual([ 43 + { 44 + cid: 'bafkcover', 45 + sourceUri: 'at://did:plc:abc123/standard.site.document/home', 46 + mimeType: 'image/jpeg', 47 + size: 256, 48 + path: 'cover' 49 + }, 50 + { 51 + cid: 'bafkbody', 52 + sourceUri: 'at://did:plc:abc123/standard.site.document/home', 53 + mimeType: 'text/markdown', 54 + size: 1024, 55 + path: 'body' 56 + } 57 + ]); 58 + }); 59 + 60 + it('only treats image and video blobs as previewable media', () => { 61 + const [image, document] = blobReferences( 62 + { 63 + image: { ref: { $link: 'bafkimage' }, mimeType: 'image/png', size: 1 }, 64 + document: { ref: { $link: 'bafkdocument' }, mimeType: 'text/markdown', size: 1 } 65 + }, 66 + null 67 + ); 68 + 69 + expect(isRenderableBlob(image!)).toBe(true); 70 + expect(isRenderableBlob(document!)).toBe(false); 24 71 }); 25 72 });
+48 -2
src/lib/atproto/repo.svelte.ts
··· 61 61 } 62 62 } 63 63 64 + async loadCollectionRoute(identity: AccountIdentity, collectionName: string) { 65 + if (this.loadedDid === identity.did && this.collections.length > 0) { 66 + this.ensureCollection(collectionName); 67 + await this.selectCollection(identity, collectionName); 68 + return; 69 + } 70 + 71 + this.reset(); 72 + this.loadedDid = identity.did; 73 + this.selectedCollection = collectionName; 74 + this.isLoadingCollections = true; 75 + this.error = null; 76 + 77 + try { 78 + const repo = await describeRepo(identity); 79 + const collectionNames = repo.collections.some((name) => name === collectionName) 80 + ? repo.collections 81 + : [collectionName, ...repo.collections]; 82 + 83 + this.collections = collectionNames.sort().map((name) => collectionSummaryForName(name, null)); 84 + await this.selectCollection(identity, collectionName); 85 + } catch (unknownError) { 86 + const loadedFromCache = await this.loadRecordsFromCache(identity, collectionName); 87 + if (this.collections.length === 0) { 88 + this.collections = [collectionSummaryForName(collectionName, null)]; 89 + } 90 + this.error = loadedFromCache 91 + ? `Showing cached records. ${errorMessage(unknownError, `Could not load records for ${collectionName}.`)}` 92 + : errorMessage(unknownError, `Could not load records for ${collectionName}.`); 93 + } finally { 94 + this.isLoadingCollections = false; 95 + } 96 + } 97 + 98 + private ensureCollection(collectionName: string) { 99 + if (this.collections.some((collection) => collection.name === collectionName)) return; 100 + 101 + this.collections = [...this.collections, collectionSummaryForName(collectionName, null)].sort((a, b) => 102 + a.name.localeCompare(b.name) 103 + ); 104 + } 105 + 64 106 async selectCollection(identity: AccountIdentity, collectionName: string) { 65 107 this.selectedCollection = collectionName; 66 108 this.selectedRecord = null; ··· 176 218 this.error = null; 177 219 178 220 try { 179 - const rpc = createRepoClient(identity); 180 221 const [repo, record] = await Promise.all([ 181 - ok(rpc.get('com.atproto.repo.describeRepo', { params: { repo: identity.did as ActorIdentifier } })), 222 + describeRepo(identity), 182 223 getRepoRecord(identity, collectionName, rkey) 183 224 ]); 184 225 const collectionNames = repo.collections.some((name) => name === collectionName) ··· 266 307 267 308 function createRepoClient(identity: AccountIdentity) { 268 309 return new Client({ handler: simpleFetchHandler({ service: identity.pds ?? 'https://public.api.bsky.app' }) }); 310 + } 311 + 312 + function describeRepo(identity: AccountIdentity) { 313 + const rpc = createRepoClient(identity); 314 + return ok(rpc.get('com.atproto.repo.describeRepo', { params: { repo: identity.did as ActorIdentifier } })); 269 315 } 270 316 271 317 async function getRepoRecord(identity: AccountIdentity, collectionName: string, rkey: string): Promise<UnknownRecord> {
+15
src/lib/atproto/session.svelte.ts
··· 1 + import type { AccountIdentity } from './types'; 2 + 3 + class RepoSessionState { 4 + identity = $state<AccountIdentity | null>(null); 5 + 6 + set(identity: AccountIdentity) { 7 + this.identity = identity; 8 + } 9 + 10 + clear() { 11 + this.identity = null; 12 + } 13 + } 14 + 15 + export const repoSession = new RepoSessionState();
+17 -2
src/lib/atproto/types.ts
··· 38 38 appLabel: string | null; 39 39 }; 40 40 41 - export type BlobReference = { cid: string; sourceUri: string | null; sourceIcon?: string }; 41 + export type BlobReference = { 42 + cid: string; 43 + sourceUri: string | null; 44 + sourceIcon?: string; 45 + mimeType: string | null; 46 + size: number | null; 47 + path: string; 48 + }; 42 49 43 - export type RepoBlobSummary = { cid: string; rawUrl: string; sourceUri: string | null; sourceIcon?: string }; 50 + export type RepoBlobSummary = { 51 + cid: string; 52 + rawUrl: string; 53 + sourceUri: string | null; 54 + sourceIcon?: string; 55 + mimeType: string | null; 56 + size: number | null; 57 + path: string | null; 58 + }; 44 59 45 60 export type RecordRouteParams = { did: string; collection: string; rkey: string }; 46 61
+22 -29
src/lib/components/CollectionBrowser.svelte
··· 1 1 <script lang="ts"> 2 2 import { goto } from '$app/navigation'; 3 3 import { resolve } from '$app/paths'; 4 + import { page } from '$app/state'; 4 5 import { onMount } from 'svelte'; 5 - import { firstBlobReference, repoBlobs } from '$lib/atproto/blobs.svelte'; 6 + import { repoSession } from '$lib/atproto/session.svelte'; 6 7 import { accountSetup } from '$lib/atproto/setup.svelte'; 7 8 import { repoBrowser } from '$lib/atproto/repo.svelte'; 8 - import { blobPath, collectionPath, identityPath, recordPath } from '$lib/atproto/routes'; 9 + import { collectionPath, identityPath, recordPath } from '$lib/atproto/routes'; 9 10 import { isRecordValue } from '$lib/atproto/types'; 10 11 import type { CollectionSummary, RepoRecordSummary } from '$lib/atproto/types'; 11 12 import { SvelteMap } from 'svelte/reactivity'; ··· 31 32 if (!reverseRecords) return repoBrowser.records; 32 33 return [...repoBrowser.records].reverse(); 33 34 }); 35 + const identity = $derived(repoSession.identity ?? (page.route.id === '/browse' ? accountSetup.identity : null)); 36 + const canUseAsDefault = $derived(Boolean(identity && identity.did !== accountSetup.identity?.did)); 34 37 type RepoPathname = `/repos/${string}`; 35 38 const navigateTo = goto as (url: string, options?: Parameters<typeof goto>[1]) => ReturnType<typeof goto>; 36 39 37 40 onMount(() => { 38 - const identity = accountSetup.identity; 39 - 40 - if (identity) { 41 + if (page.route.id === '/browse' && identity && repoBrowser.loadedDid !== identity.did) { 42 + repoSession.set(identity); 41 43 repoBrowser.load(identity); 42 44 } 43 45 }); 44 46 45 47 function selectCollection(collectionName: string) { 46 - const identity = accountSetup.identity; 47 - 48 48 if (identity) { 49 49 searchQuery = ''; 50 50 navigate(collectionPath({ did: identity.did, collection: collectionName })); ··· 52 52 } 53 53 54 54 function searchRecords() { 55 - const identity = accountSetup.identity; 56 - 57 55 if (identity) { 58 56 repoBrowser.searchRecords(identity, searchQuery); 59 57 } ··· 65 63 } 66 64 67 65 function openRecord(record: RepoRecordSummary) { 68 - const identity = accountSetup.identity; 69 - const blob = firstBlobReference(record.value, record.uri); 70 - 71 - if (identity && blob) { 72 - repoBrowser.selectedRecord = record; 73 - repoBlobs.openMedia(identity, { ...blob, sourceIcon: record.icon }); 74 - navigate(blobPath(identity.did, blob.cid)); 75 - return; 76 - } 77 - 78 66 repoBrowser.selectedRecord = record; 79 67 80 68 if (identity) { ··· 83 71 } 84 72 85 73 function openIdentityInspector() { 86 - const identity = accountSetup.identity; 87 74 if (!identity) return; 88 75 89 76 navigate(identityPath(identity.did)); 90 77 } 91 78 92 79 function updatePageSize(event: Event) { 93 - const identity = accountSetup.identity; 94 80 const select = event.currentTarget; 95 81 96 82 if (!(select instanceof HTMLSelectElement) || !identity) return; ··· 136 122 137 123 function navigate(path: string) { 138 124 void navigateTo(resolve(path as RepoPathname), { keepFocus: true, noScroll: true }); 125 + } 126 + 127 + function useCurrentRepoAsDefault() { 128 + if (!identity) return; 129 + accountSetup.save(identity); 139 130 } 140 131 141 132 function groupCollections(collections: CollectionSummary[]) { ··· 170 161 <aside class="sidebar" aria-label="Collections"> 171 162 <header> 172 163 <h2>Collections</h2> 173 - <p>{accountSetup.identity?.handle ?? 'at:// repo folders'}</p> 164 + <p>{identity?.handle ?? 'at:// repo folders'}</p> 174 165 </header> 175 166 176 167 <label class="collection-filter"> ··· 231 222 {/if} 232 223 <p> 233 224 {#if repoBrowser.isLoadingRecords} 234 - Fetching public records from {accountSetup.identity?.pds ?? 'the public API'}… 225 + Fetching public records from {identity?.pds ?? 'the public API'}… 235 226 {:else if repoBrowser.searchQuery} 236 227 Searching cached records for “{repoBrowser.searchQuery}”. 237 228 {:else} ··· 289 280 {/if} 290 281 </div> 291 282 </form> 292 - <button 293 - class="identity-launcher" 294 - type="button" 295 - disabled={!accountSetup.identity} 296 - onclick={openIdentityInspector}> 283 + <button class="identity-launcher" type="button" disabled={!identity} onclick={openIdentityInspector}> 297 284 <img src="/icons/humanity/apps/identity-inspector.svg" alt="" width="22" height="22" /> 298 285 <span>Identity</span> 299 286 </button> 287 + {#if canUseAsDefault} 288 + <button class="identity-launcher" type="button" onclick={useCurrentRepoAsDefault}> 289 + <img src="/icons/humanity/places/user-home.svg" alt="" width="22" height="22" /> 290 + <span>Use as default</span> 291 + </button> 292 + {/if} 300 293 </div> 301 294 </div> 302 295 ··· 337 330 class="load-more-records" 338 331 type="button" 339 332 disabled={repoBrowser.isLoadingMoreRecords} 340 - onclick={() => accountSetup.identity && repoBrowser.loadNextRecordPage(accountSetup.identity)}> 333 + onclick={() => identity && repoBrowser.loadNextRecordPage(identity)}> 341 334 {repoBrowser.isLoadingMoreRecords ? 'Loading more records…' : 'Load more records'} 342 335 </button> 343 336 {/if}
+3 -4
src/lib/components/EyeOfGnome.svelte
··· 1 1 <script lang="ts"> 2 2 import { repoBlobs } from '$lib/atproto/blobs.svelte'; 3 - import { accountSetup } from '$lib/atproto/setup.svelte'; 3 + import { repoSession } from '$lib/atproto/session.svelte'; 4 4 5 5 let previewMode = $state<'image' | 'video' | 'unsupported'>('image'); 6 6 let copied = $state(false); 7 7 8 8 const selectedBlob = $derived(repoBlobs.selectedBlob); 9 + const identity = $derived(repoSession.identity); 9 10 const canGoPrevious = $derived(repoBlobs.selectedIndex > 0); 10 11 const canGoNext = $derived(repoBlobs.selectedIndex >= 0 && repoBlobs.selectedIndex < repoBlobs.blobs.length - 1); 11 12 12 13 function selectBlob(cid: string) { 13 - const identity = accountSetup.identity; 14 14 if (!identity) return; 15 15 repoBlobs.select(identity, cid); 16 16 resetPreview(); ··· 32 32 } 33 33 34 34 function loadMore() { 35 - const identity = accountSetup.identity; 36 35 if (!identity) return; 37 36 void repoBlobs.loadMore(identity); 38 37 } ··· 60 59 <img src="/icons/humanity/apps/eog.svg" alt="" width="32" height="32" /> 61 60 <div> 62 61 <h2>Image Viewer</h2> 63 - <p>{accountSetup.identity?.handle ?? 'at:// blobs'}</p> 62 + <p>{identity?.handle ?? 'at:// blobs'}</p> 64 63 </div> 65 64 </header> 66 65
+130 -13
src/lib/components/Gedit.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 3 import type { ThemeRegistration } from 'shiki'; 4 - import { accountSetup } from '$lib/atproto/setup.svelte'; 4 + import { blobPath } from '$lib/atproto/routes'; 5 + import { blobReferences, isRenderableBlob, rawBlobUrl, repoBlobs } from '$lib/atproto/blobs.svelte'; 6 + import { repoSession } from '$lib/atproto/session.svelte'; 5 7 import { isRecordValue } from '$lib/atproto/types'; 6 - import type { RepoRecordSummary } from '$lib/atproto/types'; 8 + import type { BlobReference, RepoRecordSummary } from '$lib/atproto/types'; 7 9 import { truncate } from '$lib/utils/text'; 10 + import { goto } from '$app/navigation'; 11 + import { resolve } from '$app/paths'; 8 12 9 13 type Props = { record: RepoRecordSummary }; 10 14 type TokenLine = Array<{ content: string; color?: string; fontStyle?: number }>; ··· 17 21 let wordWrap = $state(false); 18 22 let copied = $state(false); 19 23 let copyTimeout: ReturnType<typeof setTimeout> | null = null; 24 + type RepoPathname = `/repos/${string}`; 25 + const navigateTo = goto as (url: string, options?: Parameters<typeof goto>[1]) => ReturnType<typeof goto>; 20 26 21 27 const recordType = $derived.by(() => { 22 28 if (!isRecordValue(record.value)) return record.collection; ··· 27 33 return record.collection; 28 34 }); 29 35 const schemaFields = $derived.by(() => schemaFieldsForRecord(record.value)); 36 + const attachments = $derived.by(() => blobReferences(record.value, record.uri)); 37 + const identity = $derived(repoSession.identity); 30 38 const rawPdsLink = $derived.by(() => { 31 - const pds = accountSetup.identity?.pds; 32 - const repo = accountSetup.identity?.did; 39 + const pds = identity?.pds; 40 + const repo = identity?.did; 33 41 if (!pds || !repo) return null; 34 42 35 43 const params = new URLSearchParams({ repo, collection: record.collection, rkey: record.rkey }); ··· 138 146 if (summary.collection === 'app.bsky.feed.post') { 139 147 links.push({ 140 148 label: 'Bluesky', 141 - href: `https://bsky.app/profile/${accountSetup.identity?.did}/post/${summary.rkey}` 149 + href: `https://bsky.app/profile/${identity?.did ?? summary.author.replace(/^@/, '')}/post/${summary.rkey}` 142 150 }); 143 151 } 144 152 145 153 return links; 146 154 } 147 155 156 + function previewAttachment(attachment: BlobReference) { 157 + if (!identity || !isRenderableBlob(attachment)) return; 158 + 159 + repoBlobs.openMedia(identity, { ...attachment, sourceIcon: record.icon }); 160 + void navigateTo(resolve(blobPath(identity.did, attachment.cid) as RepoPathname), { 161 + keepFocus: true, 162 + noScroll: true 163 + }); 164 + } 165 + 166 + function openRawAttachment(attachment: BlobReference) { 167 + if (!identity) return; 168 + 169 + window.open(rawBlobUrl(identity, attachment.cid), '_blank', 'noopener,noreferrer'); 170 + } 171 + 172 + function attachmentSizeLabel(size: number | null) { 173 + if (size === null) return 'Unknown size'; 174 + if (size < 1024) return `${size} B`; 175 + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; 176 + return `${(size / 1024 / 1024).toFixed(1)} MB`; 177 + } 178 + 179 + function attachmentIcon(attachment: BlobReference) { 180 + if (attachment.mimeType?.startsWith('image/')) return '/icons/humanity/mimes/image-x-generic.svg'; 181 + if (attachment.mimeType?.startsWith('video/')) return '/icons/humanity/mimes/video-x-generic.svg'; 182 + if (attachment.mimeType === 'application/pdf') return '/icons/humanity/mimes/gnome-mime-application-pdf.svg'; 183 + return '/icons/humanity/mimes/text-x-generic.svg'; 184 + } 185 + 148 186 onMount(() => { 149 187 void highlight(); 150 - }); 151 - 152 - $effect(() => { 153 - if (record.uri) void highlight(); 154 188 }); 155 189 </script> 156 190 ··· 268 302 <dd>{record.rkey}</dd> 269 303 </div> 270 304 <div> 271 - <dt>Read-only verification</dt> 305 + <dt>Read-only?</dt> 272 306 <dd> 273 307 {#if record.cid} 274 308 Loaded as a public repo record with an immutable CID. ··· 297 331 {/each} 298 332 </ul> 299 333 </section> 334 + 335 + {#if attachments.length > 0} 336 + <section class="attachment-list" aria-label="Blob attachments"> 337 + <h2>Blob attachments</h2> 338 + <ul> 339 + {#each attachments as attachment (`${attachment.path}-${attachment.cid}`)} 340 + <li> 341 + <img src={attachmentIcon(attachment)} alt="" width="24" height="24" /> 342 + <div> 343 + <strong>{attachment.path}</strong> 344 + <span>{attachment.mimeType ?? 'Unknown MIME'} · {attachmentSizeLabel(attachment.size)}</span> 345 + <code>{attachment.cid}</code> 346 + </div> 347 + <div class="attachment-actions"> 348 + {#if isRenderableBlob(attachment)} 349 + <button type="button" onclick={() => previewAttachment(attachment)}>Preview</button> 350 + {/if} 351 + <button type="button" onclick={() => openRawAttachment(attachment)}>Raw blob</button> 352 + </div> 353 + </li> 354 + {/each} 355 + </ul> 356 + </section> 357 + {/if} 300 358 </div> 301 359 {/if} 302 360 ··· 566 624 gap: var(--space-2); 567 625 } 568 626 569 - .external-links ul { 627 + .external-links ul, 628 + .attachment-list ul { 570 629 display: flex; 571 630 flex-wrap: wrap; 572 631 gap: var(--space-2); ··· 575 634 list-style: none; 576 635 } 577 636 578 - .external-links a { 579 - display: inline-block; 637 + .external-links a, 638 + .attachment-actions button { 639 + display: flex; 640 + align-items: center; 580 641 padding: var(--space-2) var(--space-3); 581 642 color: var(--base01); 582 643 background: linear-gradient(#eeeeec, #b3b7b0); ··· 587 648 font-size: var(--text-1); 588 649 font-weight: 700; 589 650 text-decoration: none; 651 + } 652 + 653 + .attachment-list { 654 + display: grid; 655 + gap: var(--space-2); 656 + } 657 + 658 + .attachment-list h2 { 659 + color: var(--base07); 660 + font-size: var(--text-3); 661 + line-height: var(--leading-tight); 662 + } 663 + 664 + .attachment-list ul { 665 + display: grid; 666 + } 667 + 668 + .attachment-list li { 669 + display: grid; 670 + grid-template-columns: 24px minmax(0, 1fr) auto; 671 + gap: var(--space-2); 672 + align-items: center; 673 + padding: var(--space-2); 674 + background: var(--base10); 675 + border: 1px solid var(--base02); 676 + } 677 + 678 + .attachment-list li > div { 679 + display: grid; 680 + gap: 2px; 681 + min-width: 0; 682 + } 683 + 684 + .attachment-list strong, 685 + .attachment-list span, 686 + .attachment-list code { 687 + overflow: hidden; 688 + text-overflow: ellipsis; 689 + white-space: nowrap; 690 + } 691 + 692 + .attachment-list strong { 693 + color: var(--base07); 694 + font-size: var(--text-1); 695 + } 696 + 697 + .attachment-list span, 698 + .attachment-list code { 699 + color: var(--base05); 700 + font-family: var(--font-mono); 701 + font-size: var(--text-0); 702 + } 703 + 704 + .attachment-actions { 705 + display: flex; 706 + gap: var(--space-1); 590 707 } 591 708 592 709 .gedit-code {
+13 -5
src/lib/components/GnomeMenuBar.svelte
··· 1 1 <script lang="ts"> 2 2 import { goto } from '$app/navigation'; 3 3 import { resolve } from '$app/paths'; 4 + import { repoSession } from '$lib/atproto/session.svelte'; 4 5 import { accountSetup } from '$lib/atproto/setup.svelte'; 5 6 import { repoBrowser } from '$lib/atproto/repo.svelte'; 7 + import { collectionPath } from '$lib/atproto/routes'; 6 8 import { desktopSession } from '$lib/desktop-session.svelte'; 7 9 import { windowManager } from '$lib/window-manager.svelte'; 8 10 import GnomeTray from '$lib/components/GnomeTray.svelte'; 9 11 12 + type RepoPathname = `/repos/${string}`; 13 + const navigateTo = goto as (url: string, options?: Parameters<typeof goto>[1]) => ReturnType<typeof goto>; 14 + 10 15 function changeAccount() { 11 16 windowManager.close('gedit'); 12 17 repoBrowser.reset(); 18 + repoSession.clear(); 13 19 accountSetup.reset(); 14 20 void goto(resolve('/browse')); 15 21 } 16 22 17 23 function openCollection(collectionName: string) { 18 - if (!accountSetup.identity) return; 24 + const identity = repoSession.identity ?? accountSetup.identity; 25 + if (!identity) return; 19 26 20 - void goto(resolve('/browse')); 21 - repoBrowser.selectCollection(accountSetup.identity, collectionName); 27 + void navigateTo(resolve(collectionPath({ did: identity.did, collection: collectionName }) as RepoPathname)); 22 28 } 23 29 </script> 24 30 ··· 48 54 <details class="panel-menu"> 49 55 <summary>System</summary> 50 56 <div class="menu-popover system-popover"> 51 - {#if accountSetup.identity} 52 - <p class="menu-heading">Browsing @{accountSetup.identity.handle}</p> 57 + {#if repoSession.identity} 58 + <p class="menu-heading">Viewing @{repoSession.identity.handle}</p> 59 + {:else if accountSetup.identity} 60 + <p class="menu-heading">Default @{accountSetup.identity.handle}</p> 53 61 {/if} 54 62 <button type="button" onclick={() => desktopSession.lock()}> 55 63 <img src="/icons/humanity/status/network-wireless-encrypted.svg" alt="" width="16" height="16" />
+3 -3
src/lib/components/IdentityInspector.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 - import { accountSetup } from '$lib/atproto/setup.svelte'; 3 + import { repoSession } from '$lib/atproto/session.svelte'; 4 4 import { inspectIdentity, type IdentityCheck, type IdentityInspection } from '$lib/atproto/identity-inspector'; 5 5 import { errorMessage } from '$lib/utils/errors'; 6 6 ··· 10 10 let error = $state<string | null>(null); 11 11 let copiedValue = $state<string | null>(null); 12 12 13 - const identity = $derived(accountSetup.identity); 13 + const identity = $derived(repoSession.identity); 14 14 const keyrings = $derived.by(() => { 15 15 if (!inspection) return []; 16 16 ··· 44 44 45 45 async function loadInspection() { 46 46 if (!identity) { 47 - error = 'No repository identity is configured.'; 47 + error = 'No repository identity is loaded.'; 48 48 return; 49 49 } 50 50
+2
src/lib/components/SetupDialog.svelte
··· 2 2 import { goto } from '$app/navigation'; 3 3 import { resolve } from '$app/paths'; 4 4 import { hydratePublicIdentity, resolveAccount, searchActorsTypeahead } from '$lib/atproto/identity'; 5 + import { repoSession } from '$lib/atproto/session.svelte'; 5 6 import type { AccountIdentity, ActorTypeaheadResult } from '$lib/atproto/types'; 6 7 import { accountSetup, setupDefaults } from '$lib/atproto/setup.svelte'; 7 8 import { errorMessage } from '$lib/utils/errors'; ··· 119 120 function useAccount() { 120 121 if (resolvedIdentity) { 121 122 accountSetup.save(resolvedIdentity); 123 + repoSession.set(resolvedIdentity); 122 124 void goto(resolve('/browse')); 123 125 } 124 126 }
+17 -11
src/routes/+layout.svelte
··· 4 4 import { afterNavigate, goto } from '$app/navigation'; 5 5 import { resolve } from '$app/paths'; 6 6 import { page } from '$app/state'; 7 + import { repoSession } from '$lib/atproto/session.svelte'; 7 8 import { repoBrowser } from '$lib/atproto/repo.svelte'; 8 9 import { collectionPath, repoPath } from '$lib/atproto/routes'; 9 10 import { accountSetup } from '$lib/atproto/setup.svelte'; ··· 61 62 const documentViewerWindow = $derived(windowManager.getWindow('document-viewer')); 62 63 const identityInspectorWindow = $derived(windowManager.getWindow('identity-inspector')); 63 64 const eogWindow = $derived(windowManager.getWindow('eog')); 65 + const repoIdentity = $derived(repoSession.identity ?? accountSetup.identity); 64 66 const shortcuts = $derived([ 65 67 { 66 68 label: 'ibex Home', ··· 85 87 icon: '/icons/humanity/apps/identity-inspector.svg', 86 88 selected: identityInspectorWindow?.isOpen && !identityInspectorWindow.isMinimized, 87 89 onactivate: () => { 88 - if (accountSetup.identity) { 89 - void goto(resolve(`/repos/${accountSetup.identity.did}/identity`), { keepFocus: true, noScroll: true }); 90 + if (repoIdentity) { 91 + void goto(resolve(`/repos/${repoIdentity.did}/identity`), { keepFocus: true, noScroll: true }); 90 92 return; 91 93 } 92 94 ··· 99 101 icon: '/icons/humanity/apps/eog.svg', 100 102 selected: eogWindow?.isOpen && !eogWindow.isMinimized, 101 103 onactivate: () => { 102 - if (accountSetup.identity) { 103 - void goto(resolve(`/repos/${accountSetup.identity.did}/blobs`), { keepFocus: true, noScroll: true }); 104 + if (repoIdentity) { 105 + void goto(resolve(`/repos/${repoIdentity.did}/blobs`), { keepFocus: true, noScroll: true }); 104 106 return; 105 107 } 106 108 ··· 205 207 } 206 208 207 209 function closeRecordWindow() { 208 - const identity = accountSetup.identity; 210 + const identity = repoIdentity; 209 211 const record = repoBrowser.selectedRecord; 210 212 211 213 if (identity && record) { ··· 217 219 } 218 220 219 221 function closeIdentityInspector() { 220 - const identity = accountSetup.identity; 222 + const identity = repoIdentity; 221 223 222 224 if (identity) { 223 225 navigate(repoPath(identity.did)); ··· 228 230 } 229 231 230 232 function closeEyeOfGnome() { 231 - const identity = accountSetup.identity; 233 + const identity = repoIdentity; 232 234 233 235 if (identity) { 234 236 navigate(repoPath(identity.did)); ··· 359 361 onminimize={() => windowManager.minimize('gedit')} 360 362 onmaximize={() => windowManager.toggleMaximize('gedit')} 361 363 onclose={closeRecordWindow}> 362 - <Gedit record={repoBrowser.selectedRecord} /> 364 + {#key repoBrowser.selectedRecord.uri} 365 + <Gedit record={repoBrowser.selectedRecord} /> 366 + {/key} 363 367 </NativeWindow> 364 368 </div> 365 369 {/if} ··· 373 377 windowId="identity-inspector" 374 378 title={identityInspectorWindow.title} 375 379 icon="/icons/humanity/apps/identity-inspector.svg" 376 - address={accountSetup.identity ? `/repos/${accountSetup.identity.did}/identity` : undefined} 380 + address={repoIdentity ? `/repos/${repoIdentity.did}/identity` : undefined} 377 381 maximized={identityInspectorWindow.isMaximized} 378 382 onfocus={() => windowManager.focus('identity-inspector')} 379 383 onminimize={() => windowManager.minimize('identity-inspector')} 380 384 onmaximize={() => windowManager.toggleMaximize('identity-inspector')} 381 385 onclose={closeIdentityInspector}> 382 - <IdentityInspector /> 386 + {#key repoIdentity?.did} 387 + <IdentityInspector /> 388 + {/key} 383 389 </AppWindow> 384 390 </div> 385 391 {/if} ··· 390 396 windowId="eog" 391 397 title={eogWindow.title} 392 398 icon="/icons/humanity/apps/eog.svg" 393 - address={accountSetup.identity ? `/repos/${accountSetup.identity.did}/blobs` : undefined} 399 + address={repoIdentity ? `/repos/${repoIdentity.did}/blobs` : undefined} 394 400 showMenubar={false} 395 401 showToolbar={false} 396 402 maximized={eogWindow.isMaximized}
+16 -7
src/routes/repos/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import { afterNavigate } from '$app/navigation'; 3 3 import { page } from '$app/state'; 4 - import { onDestroy } from 'svelte'; 4 + import { onDestroy, onMount } from 'svelte'; 5 5 import { repoBlobs } from '$lib/atproto/blobs.svelte'; 6 6 import { hydratePublicIdentity } from '$lib/atproto/identity'; 7 + import { repoSession } from '$lib/atproto/session.svelte'; 7 8 import { repoBrowser } from '$lib/atproto/repo.svelte'; 8 - import { accountSetup } from '$lib/atproto/setup.svelte'; 9 9 import CollectionBrowser from '$lib/components/CollectionBrowser.svelte'; 10 10 import { repoRouteKey, type RepoRoute } from '$lib/desktop-routes'; 11 11 import { errorMessage } from '$lib/utils/errors'; ··· 14 14 let { children } = $props(); 15 15 let handledRouteKey: string | null = null; 16 16 17 + onMount(() => { 18 + openCurrentRoute(); 19 + }); 20 + 17 21 afterNavigate(() => { 22 + openCurrentRoute(); 23 + }); 24 + 25 + function openCurrentRoute() { 18 26 const route = page.data.repoRoute; 19 27 if (!route) return; 20 28 ··· 23 31 24 32 handledRouteKey = routeKey; 25 33 void openRepoRoute(route); 26 - }); 34 + } 27 35 28 36 onDestroy(() => { 29 37 closeRoutedWindows(); ··· 32 40 async function openRepoRoute(route: RepoRoute) { 33 41 try { 34 42 const identity = await hydratePublicIdentity(route.did); 35 - accountSetup.save(identity); 43 + repoSession.set(identity); 36 44 windowManager.restore('main'); 37 45 closeWindowsOutsideRoute(route); 38 46 ··· 67 75 return; 68 76 } 69 77 70 - await repoBrowser.load(identity); 71 - 72 78 if (route.kind === 'collection') { 73 - await repoBrowser.selectCollection(identity, route.collection); 79 + await repoBrowser.loadCollectionRoute(identity, route.collection); 80 + return; 74 81 } 82 + 83 + await repoBrowser.load(identity); 75 84 } catch (unknownError) { 76 85 repoBrowser.error = errorMessage(unknownError, 'Could not open that repository route.'); 77 86 }