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: open blobs in EOG by default

Owais Jamil (Jun 19, 2026, 1:50 PM -0500) 5e9d8a4f 5410e01e

+327 -16
+5
.changeset/kind-apples-kick.md
··· 1 + --- 2 + 'intrepid-ibex': minor 3 + --- 4 + 5 + open blobs in eog by default
+2 -2
TODO.md
··· 43 43 selection. 44 44 - Add collection schema tabs and links into System Monitor for the selected repo or 45 45 collection. 46 - - [ ] **gedit** 46 + - [x] **gedit** 47 47 - Read-only JSON viewer for records. 48 48 - Preserve copy, wrapping, syntax highlighting, and native GTK-style window behavior. 49 49 - Add record tabs for JSON, schema, backlinks, and info. 50 50 - Show AT URI, CID, raw PDS link, external app links, and read-only verification status. 51 - - [ ] **Eye of GNOME (eog)** 51 + - [x] **Eye of GNOME (eog)** 52 52 - List public repo blob CIDs via `com.atproto.sync.listBlobs`. 53 53 - Open from Nautilus when a record contains embedded images or media blobs. 54 54 - Preview image/video blobs, with `app.bsky.feed.post` embedded images as the first
+35
src/lib/atproto/blobs.svelte.ts
··· 168 168 } 169 169 170 170 export function firstBlobReference(value: unknown, sourceUri: string | null): BlobReference | null { 171 + const postImage = firstPostImageBlobReference(value, sourceUri); 172 + if (postImage) return postImage; 173 + 171 174 return blobReferences(value, sourceUri)[0] ?? null; 172 175 } 173 176 ··· 208 211 } 209 212 210 213 return references; 214 + } 215 + 216 + function firstPostImageBlobReference(value: unknown, sourceUri: string | null): BlobReference | null { 217 + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; 218 + 219 + const record = value as Record<string, unknown>; 220 + if (record.$type !== 'app.bsky.feed.post') return null; 221 + 222 + const embed = record.embed; 223 + if (typeof embed !== 'object' || embed === null || Array.isArray(embed)) return null; 224 + 225 + const embedRecord = embed as Record<string, unknown>; 226 + if (embedRecord.$type !== 'app.bsky.embed.images' || !Array.isArray(embedRecord.images)) return null; 227 + 228 + for (const [index, image] of embedRecord.images.entries()) { 229 + if (typeof image !== 'object' || image === null || Array.isArray(image)) continue; 230 + 231 + const imageRecord = image as Record<string, unknown>; 232 + const blob = imageRecord.image; 233 + if (typeof blob !== 'object' || blob === null || Array.isArray(blob)) continue; 234 + if (!isBlobObject(blob)) continue; 235 + 236 + return { 237 + cid: blob.ref.$link, 238 + sourceUri, 239 + mimeType: typeof blob.mimeType === 'string' ? blob.mimeType : null, 240 + size: typeof blob.size === 'number' ? blob.size : null, 241 + path: `embed.images.[${index}].image` 242 + }; 243 + } 244 + 245 + return null; 211 246 } 212 247 213 248 export const repoBlobs = new RepoBlobState();
+19
src/lib/atproto/blobs.test.ts
··· 25 25 }); 26 26 }); 27 27 28 + it('prioritizes app.bsky.feed.post embedded images over other blob fields', () => { 29 + const blob = firstBlobReference( 30 + { 31 + $type: 'app.bsky.feed.post', 32 + cover: { ref: { $link: 'bafkcover' }, mimeType: 'image/png', size: 222 }, 33 + embed: { 34 + $type: 'app.bsky.embed.images', 35 + images: [ 36 + { alt: 'first post image', image: { ref: { $link: 'bafkpostimage' }, mimeType: 'image/jpeg', size: 111 } } 37 + ] 38 + } 39 + }, 40 + 'at://did:plc:abc123/app.bsky.feed.post/post1' 41 + ); 42 + 43 + expect(blob?.cid).toBe('bafkpostimage'); 44 + expect(blob?.path).toBe('embed.images.[0].image'); 45 + }); 46 + 28 47 it('returns null when a record has no blob ref', () => { 29 48 expect(firstBlobReference({ text: 'plain record' }, 'at://did:plc:abc123/app.bsky.feed.post/post2')).toBeNull(); 30 49 });
+9 -1
src/lib/components/CollectionBrowser.svelte
··· 5 5 import { onMount } from 'svelte'; 6 6 import { repoSession } from '$lib/atproto/session.svelte'; 7 7 import { accountSetup } from '$lib/atproto/setup.svelte'; 8 + import { firstBlobReference, isRenderableBlob, repoBlobs } from '$lib/atproto/blobs.svelte'; 8 9 import { repoBrowser } from '$lib/atproto/repo.svelte'; 9 - import { collectionPath, identityPath, recordPath } from '$lib/atproto/routes'; 10 + import { blobPath, collectionPath, identityPath, recordPath } from '$lib/atproto/routes'; 10 11 import { lexiconPath } from '$lib/atproto/lexicon'; 11 12 import { isRecordValue } from '$lib/atproto/types'; 12 13 import type { CollectionSummary, RepoRecordSummary } from '$lib/atproto/types'; ··· 67 68 function openRecord(record: RepoRecordSummary) { 68 69 repoBrowser.selectedRecord = record; 69 70 if (identity) { 71 + const firstMediaBlob = firstBlobReference(record.value, record.uri); 72 + if (firstMediaBlob && isRenderableBlob(firstMediaBlob)) { 73 + repoBlobs.openMedia(identity, { ...firstMediaBlob, sourceIcon: record.icon }); 74 + navigate(blobPath(identity.did, firstMediaBlob.cid)); 75 + return; 76 + } 77 + 70 78 navigate(recordPath({ did: identity.did, collection: record.collection, rkey: record.rkey })); 71 79 } 72 80 }
+112 -2
src/lib/components/EyeOfGnome.svelte
··· 1 1 <script lang="ts"> 2 + import { goto } from '$app/navigation'; 3 + import { resolve } from '$app/paths'; 2 4 import { repoBlobs } from '$lib/atproto/blobs.svelte'; 5 + import { blobsPath, recordPath } from '$lib/atproto/routes'; 3 6 import { repoSession } from '$lib/atproto/session.svelte'; 4 7 8 + // TODO: this could go in routes.ts 9 + type RepoPathname = `/repos/${string}`; 10 + 5 11 let previewMode = $state<'image' | 'video' | 'unsupported'>('image'); 6 12 let copied = $state(false); 7 13 ··· 9 15 const identity = $derived(repoSession.identity); 10 16 const canGoPrevious = $derived(repoBlobs.selectedIndex > 0); 11 17 const canGoNext = $derived(repoBlobs.selectedIndex >= 0 && repoBlobs.selectedIndex < repoBlobs.blobs.length - 1); 18 + const repoBlobBrowserPath = $derived(identity ? blobsPath(identity.did) : null); 19 + 20 + const navigateTo = goto as (url: string, options?: Parameters<typeof goto>[1]) => ReturnType<typeof goto>; 12 21 13 22 function selectBlob(cid: string) { 14 23 if (!identity) return; ··· 49 58 50 59 function openSourceUri() { 51 60 if (!selectedBlob?.sourceUri) return; 52 - window.open(selectedBlob.sourceUri, '_blank', 'noopener,noreferrer'); 61 + const sourceRoute = recordPathFromAtUri(selectedBlob.sourceUri); 62 + if (sourceRoute) { 63 + void navigateTo(resolve(sourceRoute as RepoPathname), { keepFocus: true, noScroll: true }); 64 + return; 65 + } 66 + 67 + window.open(`https://pds.ls/${selectedBlob.sourceUri}`, '_blank', 'noopener,noreferrer'); 68 + } 69 + 70 + function openRepoBlobs() { 71 + if (!repoBlobBrowserPath) return; 72 + void navigateTo(resolve(repoBlobBrowserPath as RepoPathname), { keepFocus: true, noScroll: true }); 73 + } 74 + 75 + function recordPathFromAtUri(uri: string) { 76 + if (!uri.startsWith('at://')) return null; 77 + const [did, collection, rkey] = uri.slice('at://'.length).split('/'); 78 + if (!did || !collection || !rkey) return null; 79 + return recordPath({ did, collection, rkey }); 53 80 } 54 81 </script> 55 82 ··· 136 163 {/if} 137 164 </div> 138 165 166 + <div class="blob-details" aria-label="Blob details"> 167 + {#if selectedBlob} 168 + <div> 169 + <span>CID</span> 170 + <code>{selectedBlob.cid}</code> 171 + </div> 172 + <div> 173 + <span>MIME</span> 174 + <code>{selectedBlob.mimeType ?? 'Unknown'}</code> 175 + </div> 176 + <div> 177 + <span>Source path</span> 178 + <code>{selectedBlob.path ?? 'Repository blob'}</code> 179 + </div> 180 + <div class="detail-actions"> 181 + <a href={selectedBlob.rawUrl} target="_blank" rel="external noreferrer">Raw PDS URL</a> 182 + {#if selectedBlob.sourceUri} 183 + <button type="button" onclick={openSourceUri}>Source record</button> 184 + {/if} 185 + <button type="button" onclick={openRepoBlobs}>Repository blobs</button> 186 + </div> 187 + {:else} 188 + <div> 189 + <span>Repository</span> 190 + <code>{identity?.did ?? 'No repo loaded'}</code> 191 + </div> 192 + {/if} 193 + </div> 194 + 139 195 <footer class="blob-status"> 140 196 {#if selectedBlob} 141 197 <span>{selectedBlob.cid}</span> ··· 244 300 245 301 .preview-pane { 246 302 display: grid; 247 - grid-template-rows: auto minmax(0, 1fr) auto; 303 + grid-template-rows: auto minmax(0, 1fr) auto auto; 248 304 min-width: 0; 249 305 min-height: 0; 250 306 background: #fff8ec; ··· 344 400 border-color: #b55a38; 345 401 } 346 402 403 + .blob-details { 404 + display: grid; 405 + grid-template-columns: repeat(4, minmax(0, 1fr)); 406 + gap: 1px; 407 + background: #a88b63; 408 + border-top: 1px solid #a88b63; 409 + } 410 + 411 + .blob-details div { 412 + display: grid; 413 + gap: 2px; 414 + min-width: 0; 415 + padding: var(--space-2); 416 + background: #fff8ec; 417 + } 418 + 419 + .blob-details span { 420 + color: #6a4a2b; 421 + font-size: var(--text-0); 422 + font-weight: 700; 423 + text-transform: uppercase; 424 + } 425 + 426 + .blob-details code { 427 + overflow: hidden; 428 + color: #2c180d; 429 + font-family: var(--font-mono); 430 + font-size: var(--text-0); 431 + text-overflow: ellipsis; 432 + white-space: nowrap; 433 + } 434 + 435 + .blob-details .detail-actions { 436 + display: flex; 437 + flex-wrap: wrap; 438 + gap: var(--space-1); 439 + align-content: start; 440 + } 441 + 442 + .detail-actions a, 443 + .detail-actions button { 444 + color: #8a3f0b; 445 + background: transparent; 446 + border: 0; 447 + font: inherit; 448 + font-size: var(--text-1); 449 + font-weight: 700; 450 + text-decoration: underline; 451 + } 452 + 347 453 .blob-status { 348 454 display: flex; 349 455 gap: var(--space-3); ··· 368 474 369 475 .blob-sidebar { 370 476 display: none; 477 + } 478 + 479 + .blob-details { 480 + grid-template-columns: 1fr; 371 481 } 372 482 } 373 483 </style>
+144 -10
src/lib/components/Gedit.svelte
··· 13 13 14 14 type Props = { record: RepoRecordSummary }; 15 15 type TokenLine = Array<{ content: string; color?: string; fontStyle?: number }>; 16 - type RecordTab = 'json' | 'schema' | 'info'; 16 + type RecordTab = 'json' | 'schema' | 'backlinks' | 'info'; 17 17 type SchemaField = { name: string; valueType: string; preview: string }; 18 + type RecordReference = { label: string; value: string; kind: 'at-uri' | 'cid' | 'did' | 'url' }; 18 19 19 20 let { record }: Props = $props(); 20 21 let tokenLines = $state<TokenLine[]>([]); ··· 35 36 }); 36 37 37 38 const schemaFields = $derived.by(() => schemaFieldsForRecord(record.value)); 39 + const recordReferences = $derived.by(() => referencesForRecord(record.value)); 38 40 const attachments = $derived.by(() => blobReferences(record.value, record.uri)); 39 41 const identity = $derived(repoSession.identity); 40 42 const rawPdsLink = $derived.by(() => { ··· 46 48 }); 47 49 48 50 const externalLinks = $derived.by(() => linksForRecord(record)); 51 + const verificationStatus = $derived(record.cid ? 'CID present from com.atproto.repo.getRecord/listRecords.' : 'No CID was returned by the PDS.'); 49 52 50 53 const themeName = 'ubuntu-iterm2b24'; 51 54 const ubuntuTheme: ThemeRegistration = { ··· 154 157 return links; 155 158 } 156 159 160 + function referencesForRecord(value: unknown): RecordReference[] { 161 + const references = new Map<string, RecordReference>(); 162 + collectReferences(value, [], references); 163 + return [...references.values()]; 164 + } 165 + 166 + function collectReferences(value: unknown, path: string[], references: Map<string, RecordReference>) { 167 + if (typeof value === 'string') { 168 + addStringReference(value, path, references); 169 + return; 170 + } 171 + 172 + if (Array.isArray(value)) { 173 + for (const [index, item] of value.entries()) { 174 + collectReferences(item, [...path, `[${index}]`], references); 175 + } 176 + return; 177 + } 178 + 179 + if (!isRecordValue(value)) return; 180 + 181 + for (const [key, child] of Object.entries(value)) { 182 + collectReferences(child, [...path, key], references); 183 + } 184 + } 185 + 186 + function addStringReference(value: string, path: string[], references: Map<string, RecordReference>) { 187 + const kind = referenceKind(value); 188 + if (!kind) return; 189 + 190 + const label = path.length > 0 ? path.join('.') : 'value'; 191 + references.set(`${kind}:${value}`, { label, value, kind }); 192 + } 193 + 194 + function referenceKind(value: string): RecordReference['kind'] | null { 195 + if (value.startsWith('at://')) return 'at-uri'; 196 + if (value.startsWith('did:')) return 'did'; 197 + if (value.startsWith('http://') || value.startsWith('https://')) return 'url'; 198 + if (value.startsWith('bafy') || value.startsWith('bafk')) return 'cid'; 199 + return null; 200 + } 201 + 157 202 function previewAttachment(attachment: BlobReference) { 158 203 if (!identity || !isRenderableBlob(attachment)) return; 159 204 ··· 214 259 onclick={() => (activeTab = 'schema')}>Schema</button> 215 260 <button 216 261 type="button" 262 + class:active={activeTab === 'backlinks'} 263 + aria-pressed={activeTab === 'backlinks'} 264 + onclick={() => (activeTab = 'backlinks')}>Backlinks</button> 265 + <button 266 + type="button" 217 267 class:active={activeTab === 'info'} 218 268 aria-pressed={activeTab === 'info'} 219 269 onclick={() => (activeTab = 'info')}>Info</button> ··· 282 332 </tbody> 283 333 </table> 284 334 </div> 335 + {:else if activeTab === 'backlinks'} 336 + <div class="tab-panel backlinks-panel"> 337 + <section class="schema-summary" aria-label="Record backlink summary"> 338 + <img src="/icons/humanity/apps/internet-feed-reader.svg" alt="" width="38" height="38" /> 339 + <div> 340 + <p class="panel-label">Read-only references</p> 341 + <h2> 342 + {recordReferences.length} linked value{#if recordReferences.length !== 1}s{/if} 343 + </h2> 344 + <p>Outbound AT URIs, CIDs, DIDs, and web links found in this record.</p> 345 + </div> 346 + </section> 347 + 348 + <ul class="reference-list"> 349 + {#each recordReferences as reference (`${reference.label}-${reference.value}`)} 350 + <li> 351 + <span>{reference.kind}</span> 352 + <div> 353 + <strong>{reference.label}</strong> 354 + {#if reference.kind === 'url' || reference.kind === 'at-uri'} 355 + <a href={reference.value} target="_blank" rel="external noreferrer">{reference.value}</a> 356 + {:else} 357 + <code>{reference.value}</code> 358 + {/if} 359 + </div> 360 + </li> 361 + {:else} 362 + <li class="empty-reference"> 363 + <span>none</span> 364 + <div> 365 + <strong>No outbound references found</strong> 366 + <p>This record does not contain obvious AT URIs, CIDs, DIDs, or web links.</p> 367 + </div> 368 + </li> 369 + {/each} 370 + </ul> 371 + </div> 285 372 {:else} 286 373 <div class="tab-panel info-panel"> 287 374 <dl> ··· 308 395 <dd>{record.rkey}</dd> 309 396 </div> 310 397 <div> 311 - <dt>Read-only?</dt> 312 - <dd> 313 - {#if record.cid} 314 - Loaded as a public repo record with an immutable CID. 315 - {:else} 316 - Loaded as a public repo record; the PDS did not include a CID. 317 - {/if} 318 - </dd> 398 + <dt>Verification</dt> 399 + <dd>{verificationStatus}</dd> 319 400 </div> 320 401 <div> 321 402 <dt>Raw PDS link</dt> ··· 637 718 } 638 719 639 720 .external-links ul, 640 - .attachment-list ul { 721 + .attachment-list ul, 722 + .reference-list { 641 723 display: flex; 642 724 flex-wrap: wrap; 643 725 gap: var(--space-2); 644 726 margin: 0; 645 727 padding: 0; 646 728 list-style: none; 729 + } 730 + 731 + .reference-list { 732 + display: grid; 733 + } 734 + 735 + .reference-list li { 736 + display: grid; 737 + grid-template-columns: 5.5rem minmax(0, 1fr); 738 + gap: var(--space-2); 739 + align-items: start; 740 + padding: var(--space-2); 741 + background: var(--base10); 742 + border: 1px solid var(--base02); 743 + } 744 + 745 + .reference-list li > span { 746 + padding: 0.15rem var(--space-2); 747 + color: var(--base01); 748 + background: linear-gradient(#d3d7cf, #b3b7b0); 749 + border: 1px solid var(--base03); 750 + border-radius: var(--radius-1); 751 + font-family: var(--font-sans); 752 + font-size: var(--text-0); 753 + font-weight: 700; 754 + text-align: center; 755 + text-transform: uppercase; 756 + } 757 + 758 + .reference-list div { 759 + display: grid; 760 + gap: 2px; 761 + min-width: 0; 762 + } 763 + 764 + .reference-list strong, 765 + .reference-list a, 766 + .reference-list code, 767 + .reference-list p { 768 + overflow-wrap: anywhere; 769 + } 770 + 771 + .reference-list strong { 772 + color: var(--base07); 773 + font-size: var(--text-1); 774 + } 775 + 776 + .reference-list code, 777 + .reference-list p { 778 + color: var(--base05); 779 + font-family: var(--font-mono); 780 + font-size: var(--text-1); 647 781 } 648 782 649 783 .external-links a,
+1 -1
src/routes/+layout.svelte
··· 248 248 return; 249 249 } 250 250 251 - window.open('https://github.com/desertthunder.dev/ibex', '_blank', 'noopener,noreferrer'); 251 + window.open('https://github.com/desertthunder/ibex', '_blank', 'noopener,noreferrer'); 252 252 } 253 253 254 254 function isLauncherSelected(id: DesktopLauncherId) {