From dba82c811587a1736d92d25f73c6aa5d8b0b26ae Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Wed, 17 Jun 2026 12:20:35 -0500 Subject: [PATCH] feat: repo session and blob metadata --- .changeset/silent-readers-smash.md | 5 + src/lib/atproto/blobs.svelte.ts | 64 ++++++--- src/lib/atproto/blobs.test.ts | 51 ++++++- src/lib/atproto/repo.svelte.ts | 50 ++++++- src/lib/atproto/session.svelte.ts | 15 ++ src/lib/atproto/types.ts | 19 ++- src/lib/components/CollectionBrowser.svelte | 51 +++---- src/lib/components/EyeOfGnome.svelte | 7 +- src/lib/components/Gedit.svelte | 143 ++++++++++++++++++-- src/lib/components/GnomeMenuBar.svelte | 18 ++- src/lib/components/IdentityInspector.svelte | 6 +- src/lib/components/SetupDialog.svelte | 2 + src/routes/+layout.svelte | 28 ++-- src/routes/repos/+layout.svelte | 23 +++- 14 files changed, 388 insertions(+), 94 deletions(-) create mode 100644 .changeset/silent-readers-smash.md create mode 100644 src/lib/atproto/session.svelte.ts diff --git a/.changeset/silent-readers-smash.md b/.changeset/silent-readers-smash.md new file mode 100644 index 0000000..c3e1dd8 --- /dev/null +++ b/.changeset/silent-readers-smash.md @@ -0,0 +1,5 @@ +--- +'intrepid-ibex': minor +--- + +changed identity resolution to url state based for deep linking diff --git a/src/lib/atproto/blobs.svelte.ts b/src/lib/atproto/blobs.svelte.ts index 7aa3046..62f6c5d 100644 --- a/src/lib/atproto/blobs.svelte.ts +++ b/src/lib/atproto/blobs.svelte.ts @@ -3,6 +3,7 @@ import type { Did } from '@atcute/lexicons/syntax'; import type {} from '@atcute/atproto'; import { errorMessage } from '$lib/utils/errors'; import type { AccountIdentity, BlobReference, RepoBlobSummary } from './types'; +import { SvelteURL } from 'svelte/reactivity'; class RepoBlobState { blobs = $state([]); @@ -81,7 +82,15 @@ class RepoBlobState { openMedia(identity: AccountIdentity, media: BlobReference) { this.loadedDid = identity.did; - const summary = blobSummary(identity, media.cid, media.sourceUri, media.sourceIcon); + const summary = blobSummary( + identity, + media.cid, + media.sourceUri, + media.sourceIcon, + media.mimeType, + media.size, + media.path + ); if (!this.blobs.some((blob) => blob.cid === media.cid)) { this.blobs = [summary, ...this.blobs]; @@ -121,7 +130,7 @@ class RepoBlobState { } } -function isBlobObject(value: object): value is { ref: { $link: string } } { +function isBlobObject(value: object): value is { ref: { $link: string }; mimeType?: unknown; size?: unknown } { if (!('ref' in value)) return false; const { ref } = value; return ( @@ -142,40 +151,63 @@ function blobSummary( identity: AccountIdentity, cid: string, sourceUri: string | null, - sourceIcon?: string + sourceIcon?: string, + mimeType: string | null = null, + size: number | null = null, + path: string | null = null ): RepoBlobSummary { - return { cid, sourceUri, sourceIcon, rawUrl: rawBlobUrl(identity, cid) }; + return { cid, sourceUri, sourceIcon, mimeType, size, path, rawUrl: rawBlobUrl(identity, cid) }; } -function rawBlobUrl(identity: AccountIdentity, cid: string) { +export function rawBlobUrl(identity: AccountIdentity, cid: string) { const service = identity.pds ?? 'https://public.api.bsky.app'; - const url = new URL('/xrpc/com.atproto.sync.getBlob', service); + const url = new SvelteURL('/xrpc/com.atproto.sync.getBlob', service); url.searchParams.set('did', identity.did); url.searchParams.set('cid', cid); return url.toString(); } export function firstBlobReference(value: unknown, sourceUri: string | null): BlobReference | null { - if (typeof value !== 'object' || value === null) return null; + return blobReferences(value, sourceUri)[0] ?? null; +} + +export function blobReferences(value: unknown, sourceUri: string | null): BlobReference[] { + return collectBlobReferences(value, sourceUri, []); +} + +export function isRenderableBlob(reference: BlobReference) { + return Boolean(reference.mimeType?.startsWith('image/') || reference.mimeType?.startsWith('video/')); +} + +function collectBlobReferences(value: unknown, sourceUri: string | null, path: string[]): BlobReference[] { + if (typeof value !== 'object' || value === null) return []; if (isBlobObject(value)) { - return { cid: value.ref.$link, sourceUri }; + return [ + { + cid: value.ref.$link, + sourceUri, + mimeType: typeof value.mimeType === 'string' ? value.mimeType : null, + size: typeof value.size === 'number' ? value.size : null, + path: path.length > 0 ? path.join('.') : 'value' + } + ]; } if (Array.isArray(value)) { - for (const item of value) { - const found = firstBlobReference(item, sourceUri); - if (found) return found; + const references: BlobReference[] = []; + for (const [index, item] of value.entries()) { + references.push(...collectBlobReferences(item, sourceUri, [...path, `[${index}]`])); } - return null; + return references; } - for (const child of Object.values(value)) { - const found = firstBlobReference(child, sourceUri); - if (found) return found; + const references: BlobReference[] = []; + for (const [key, child] of Object.entries(value)) { + references.push(...collectBlobReferences(child, sourceUri, [...path, key])); } - return null; + return references; } export const repoBlobs = new RepoBlobState(); diff --git a/src/lib/atproto/blobs.test.ts b/src/lib/atproto/blobs.test.ts index edd60c6..5be6d59 100644 --- a/src/lib/atproto/blobs.test.ts +++ b/src/lib/atproto/blobs.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { firstBlobReference } from './blobs.svelte'; +import { blobReferences, firstBlobReference, isRenderableBlob } from './blobs.svelte'; describe('ATProto blob helpers', () => { it('finds the first embedded blob CID in a post image record', () => { @@ -16,10 +16,57 @@ describe('ATProto blob helpers', () => { 'at://did:plc:abc123/app.bsky.feed.post/post1' ); - expect(blob).toEqual({ cid: 'bafkreigoodimage', sourceUri: 'at://did:plc:abc123/app.bsky.feed.post/post1' }); + expect(blob).toEqual({ + cid: 'bafkreigoodimage', + sourceUri: 'at://did:plc:abc123/app.bsky.feed.post/post1', + mimeType: 'image/png', + size: 1234, + path: 'embed.images.[0].image' + }); }); it('returns null when a record has no blob ref', () => { expect(firstBlobReference({ text: 'plain record' }, 'at://did:plc:abc123/app.bsky.feed.post/post2')).toBeNull(); }); + + it('collects multiple blob references with MIME metadata', () => { + const references = blobReferences( + { + $type: 'standard.site.document', + cover: { ref: { $link: 'bafkcover' }, mimeType: 'image/jpeg', size: 256 }, + body: { ref: { $link: 'bafkbody' }, mimeType: 'text/markdown', size: 1024 } + }, + 'at://did:plc:abc123/standard.site.document/home' + ); + + expect(references).toEqual([ + { + cid: 'bafkcover', + sourceUri: 'at://did:plc:abc123/standard.site.document/home', + mimeType: 'image/jpeg', + size: 256, + path: 'cover' + }, + { + cid: 'bafkbody', + sourceUri: 'at://did:plc:abc123/standard.site.document/home', + mimeType: 'text/markdown', + size: 1024, + path: 'body' + } + ]); + }); + + it('only treats image and video blobs as previewable media', () => { + const [image, document] = blobReferences( + { + image: { ref: { $link: 'bafkimage' }, mimeType: 'image/png', size: 1 }, + document: { ref: { $link: 'bafkdocument' }, mimeType: 'text/markdown', size: 1 } + }, + null + ); + + expect(isRenderableBlob(image!)).toBe(true); + expect(isRenderableBlob(document!)).toBe(false); + }); }); diff --git a/src/lib/atproto/repo.svelte.ts b/src/lib/atproto/repo.svelte.ts index 25d9f9f..bfeb866 100644 --- a/src/lib/atproto/repo.svelte.ts +++ b/src/lib/atproto/repo.svelte.ts @@ -61,6 +61,48 @@ class RepoBrowserState { } } + async loadCollectionRoute(identity: AccountIdentity, collectionName: string) { + if (this.loadedDid === identity.did && this.collections.length > 0) { + this.ensureCollection(collectionName); + await this.selectCollection(identity, collectionName); + return; + } + + this.reset(); + this.loadedDid = identity.did; + this.selectedCollection = collectionName; + this.isLoadingCollections = true; + this.error = null; + + try { + const repo = await describeRepo(identity); + const collectionNames = repo.collections.some((name) => name === collectionName) + ? repo.collections + : [collectionName, ...repo.collections]; + + this.collections = collectionNames.sort().map((name) => collectionSummaryForName(name, null)); + await this.selectCollection(identity, collectionName); + } catch (unknownError) { + const loadedFromCache = await this.loadRecordsFromCache(identity, collectionName); + if (this.collections.length === 0) { + this.collections = [collectionSummaryForName(collectionName, null)]; + } + this.error = loadedFromCache + ? `Showing cached records. ${errorMessage(unknownError, `Could not load records for ${collectionName}.`)}` + : errorMessage(unknownError, `Could not load records for ${collectionName}.`); + } finally { + this.isLoadingCollections = false; + } + } + + private ensureCollection(collectionName: string) { + if (this.collections.some((collection) => collection.name === collectionName)) return; + + this.collections = [...this.collections, collectionSummaryForName(collectionName, null)].sort((a, b) => + a.name.localeCompare(b.name) + ); + } + async selectCollection(identity: AccountIdentity, collectionName: string) { this.selectedCollection = collectionName; this.selectedRecord = null; @@ -176,9 +218,8 @@ class RepoBrowserState { this.error = null; try { - const rpc = createRepoClient(identity); const [repo, record] = await Promise.all([ - ok(rpc.get('com.atproto.repo.describeRepo', { params: { repo: identity.did as ActorIdentifier } })), + describeRepo(identity), getRepoRecord(identity, collectionName, rkey) ]); const collectionNames = repo.collections.some((name) => name === collectionName) @@ -268,6 +309,11 @@ function createRepoClient(identity: AccountIdentity) { return new Client({ handler: simpleFetchHandler({ service: identity.pds ?? 'https://public.api.bsky.app' }) }); } +function describeRepo(identity: AccountIdentity) { + const rpc = createRepoClient(identity); + return ok(rpc.get('com.atproto.repo.describeRepo', { params: { repo: identity.did as ActorIdentifier } })); +} + async function getRepoRecord(identity: AccountIdentity, collectionName: string, rkey: string): Promise { const rpc = createRepoClient(identity); const record = await ok( diff --git a/src/lib/atproto/session.svelte.ts b/src/lib/atproto/session.svelte.ts new file mode 100644 index 0000000..9996a6f --- /dev/null +++ b/src/lib/atproto/session.svelte.ts @@ -0,0 +1,15 @@ +import type { AccountIdentity } from './types'; + +class RepoSessionState { + identity = $state(null); + + set(identity: AccountIdentity) { + this.identity = identity; + } + + clear() { + this.identity = null; + } +} + +export const repoSession = new RepoSessionState(); diff --git a/src/lib/atproto/types.ts b/src/lib/atproto/types.ts index 7a8b6f5..a38fd12 100644 --- a/src/lib/atproto/types.ts +++ b/src/lib/atproto/types.ts @@ -38,9 +38,24 @@ export type RepoRecordSummary = { appLabel: string | null; }; -export type BlobReference = { cid: string; sourceUri: string | null; sourceIcon?: string }; +export type BlobReference = { + cid: string; + sourceUri: string | null; + sourceIcon?: string; + mimeType: string | null; + size: number | null; + path: string; +}; -export type RepoBlobSummary = { cid: string; rawUrl: string; sourceUri: string | null; sourceIcon?: string }; +export type RepoBlobSummary = { + cid: string; + rawUrl: string; + sourceUri: string | null; + sourceIcon?: string; + mimeType: string | null; + size: number | null; + path: string | null; +}; export type RecordRouteParams = { did: string; collection: string; rkey: string }; diff --git a/src/lib/components/CollectionBrowser.svelte b/src/lib/components/CollectionBrowser.svelte index 316ab76..3d5eaf9 100644 --- a/src/lib/components/CollectionBrowser.svelte +++ b/src/lib/components/CollectionBrowser.svelte @@ -1,11 +1,12 @@
@@ -268,7 +302,7 @@
{record.rkey}
-
Read-only verification
+
Read-only?
{#if record.cid} Loaded as a public repo record with an immutable CID. @@ -297,6 +331,30 @@ {/each}
+ + {#if attachments.length > 0} +
+

Blob attachments

+
    + {#each attachments as attachment (`${attachment.path}-${attachment.cid}`)} +
  • + +
    + {attachment.path} + {attachment.mimeType ?? 'Unknown MIME'} · {attachmentSizeLabel(attachment.size)} + {attachment.cid} +
    +
    + {#if isRenderableBlob(attachment)} + + {/if} + +
    +
  • + {/each} +
+
+ {/if} {/if} @@ -566,7 +624,8 @@ gap: var(--space-2); } - .external-links ul { + .external-links ul, + .attachment-list ul { display: flex; flex-wrap: wrap; gap: var(--space-2); @@ -575,8 +634,10 @@ list-style: none; } - .external-links a { - display: inline-block; + .external-links a, + .attachment-actions button { + display: flex; + align-items: center; padding: var(--space-2) var(--space-3); color: var(--base01); background: linear-gradient(#eeeeec, #b3b7b0); @@ -589,6 +650,62 @@ text-decoration: none; } + .attachment-list { + display: grid; + gap: var(--space-2); + } + + .attachment-list h2 { + color: var(--base07); + font-size: var(--text-3); + line-height: var(--leading-tight); + } + + .attachment-list ul { + display: grid; + } + + .attachment-list li { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) auto; + gap: var(--space-2); + align-items: center; + padding: var(--space-2); + background: var(--base10); + border: 1px solid var(--base02); + } + + .attachment-list li > div { + display: grid; + gap: 2px; + min-width: 0; + } + + .attachment-list strong, + .attachment-list span, + .attachment-list code { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .attachment-list strong { + color: var(--base07); + font-size: var(--text-1); + } + + .attachment-list span, + .attachment-list code { + color: var(--base05); + font-family: var(--font-mono); + font-size: var(--text-0); + } + + .attachment-actions { + display: flex; + gap: var(--space-1); + } + .gedit-code { min-height: 100%; margin: 0; diff --git a/src/lib/components/GnomeMenuBar.svelte b/src/lib/components/GnomeMenuBar.svelte index 73be304..bfec31b 100644 --- a/src/lib/components/GnomeMenuBar.svelte +++ b/src/lib/components/GnomeMenuBar.svelte @@ -1,24 +1,30 @@ @@ -48,8 +54,10 @@
System