From bdce414ba8e06a97dc029f05d2db09aa616877dd Mon Sep 17 00:00:00 2001 From: dame Date: Thu, 16 Jul 2026 08:27:50 -0400 Subject: [PATCH] Preview images inline in the rich JSON record view (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The structured "rich JSON preview" now renders a thumbnail for image-bearing fields, so image records (e.g. is.dame.arena.mirror.block) show the picture instead of just a URL or an opaque blob. Two references are detected: - Direct image URLs in string fields (extension checked against the URL path, so query strings like arena's `?` don't defeat it). Sanitized via sanitizeUrl before rendering; the field keeps its copyable text. - AT Protocol image blobs (`$type: blob` + image mimeType, plus the legacy inline-CID shape), rendered through com.atproto.sync.getBlob against the owning PDS. Non-image blobs are ignored. Details: - RecordImageThumb is a bounded, lazy, no-referrer that opens full-size on click and fails closed (a non-image / 404 removes itself rather than leaving a broken-image glyph). - When an object holds both a direct URL and a blob of the same asset (arena's `image.src` + `image.blob`), the blob preview is suppressed so the image shows once. - The PDS is threaded via context (from the explorer's resolved identity); without it, direct-URL images still preview and blobs simply no-op — so the universal-link page keeps working with URL images only for now. Claude-Session: https://claude.ai/code/session_01DYtRT3CZZTDZgq7r3tdKfQ Co-authored-by: Claude --- src/components/RecordImageThumb.tsx | 62 +++++++++++++ src/components/RecordPreview.tsx | 101 ++++++++++++++++++---- src/components/explore/RecordExplorer.tsx | 1 + src/utils/recordImages.ts | 79 +++++++++++++++++ 4 files changed, 225 insertions(+), 18 deletions(-) create mode 100644 src/components/RecordImageThumb.tsx create mode 100644 src/utils/recordImages.ts diff --git a/src/components/RecordImageThumb.tsx b/src/components/RecordImageThumb.tsx new file mode 100644 index 0000000..af94580 --- /dev/null +++ b/src/components/RecordImageThumb.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { useState } from 'react'; + +/** + * A bounded inline image preview for the rich JSON record view. Used for both + * direct image URLs and blob-backed images (the caller resolves the src). + * + * Behaviour: + * - Lazy-loaded and `no-referrer` — record fields can point at arbitrary + * third-party hosts, so we don't eagerly fetch off-screen images or leak + * the referrer to them. + * - Fails closed: a URL that isn't actually an image (or 404s) removes the + * element instead of leaving a broken-image glyph in the field table. + * - Click opens the full image in a new tab; `stopPropagation` keeps that + * click from tripping the surrounding copy-on-click / expand affordances. + */ +export default function RecordImageThumb({ + src, + alt = '', +}: { + src: string; + alt?: string; +}) { + const [failed, setFailed] = useState(false); + if (failed) return null; + return ( + e.stopPropagation()} + title="Open full image in a new tab" + style={{ + display: 'inline-block', + marginTop: '0.5rem', + maxWidth: '100%', + lineHeight: 0, + }} + > + {/* Plain (not next/image) so arbitrary record image hosts render + without being enumerated in next.config's remotePatterns. */} + {alt} setFailed(true)} + style={{ + display: 'block', + maxWidth: '100%', + maxHeight: '320px', + width: 'auto', + height: 'auto', + objectFit: 'contain', + border: '1px solid var(--border-medium)', + background: 'var(--bg-tertiary)', + }} + /> + + ); +} diff --git a/src/components/RecordPreview.tsx b/src/components/RecordPreview.tsx index 7db4299..6f3bc15 100644 --- a/src/components/RecordPreview.tsx +++ b/src/components/RecordPreview.tsx @@ -5,13 +5,20 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { createContext, useContext, useEffect, useState } from 'react'; import Link from 'next/link'; import { GenericRecord } from '@/utils/recordFetcher'; -import { sanitizeHandle } from '@/utils/sanitize'; +import { sanitizeHandle, sanitizeUrl } from '@/utils/sanitize'; import { Check, ChevronDown, ChevronRight, Copy, Telescope } from 'lucide-react'; import { encodeRepo, explorePathFromAtUri } from '@/utils/atproto/urls'; import { resolveDidHandle } from '@/utils/atproto/identity'; +import RecordImageThumb from '@/components/RecordImageThumb'; +import { + didFromAtUri, + getBlobUrl, + imageBlobFromValue, + imageUrlFromValue, +} from '@/utils/recordImages'; type RecordPreviewProps = { record: GenericRecord; @@ -34,8 +41,26 @@ type RecordPreviewProps = { * the actions on the right. */ footerActions?: import('react').ReactNode; + /** + * Owning repo's PDS endpoint. When present, blob-backed image fields render + * an inline thumbnail (served via `com.atproto.sync.getBlob`); without it, + * only direct image URLs preview. The explorer passes it; the universal-link + * page omits it (it doesn't resolve the PDS), so blob thumbnails are + * explorer-only for now. + */ + pds?: string; }; +/** + * Supplies the repo coordinates a blob image needs to build its getBlob URL. + * Read by the recursive FieldRow so we don't thread `pds`/`did` through every + * nested level by hand. `did` comes from the record URI; both are optional so + * blob previews simply no-op when either is missing. + */ +const RecordImageContext = createContext<{ pds?: string; did: string | null }>({ + did: null, +}); + export default function RecordPreview({ record, collection, @@ -43,8 +68,10 @@ export default function RecordPreview({ rkey, hideExplorerCtas, footerActions, + pds, }: RecordPreviewProps) { const { value, cid } = record; + const did = didFromAtUri(record.uri); // Format the record type nicely const recordType = value.$type || collection; @@ -163,22 +190,24 @@ export default function RecordPreview({ beside its label or drops onto its own line — see .record-fields in globals.css. Lets the card stack correctly inside narrow split panes, not just on small screens. */} -
- {previewFields.map(([key, val]) => ( - // Inside the explorer (hideExplorerCtas) this card IS the - // canonical record view, so top-level fields start expanded — - // the visitor came here to read the record, not to click each - // object/array chip open. Nested rows stay collapsed so deep - // structures don't unfurl all at once. On universal-link pages - // the preview stays compact (everything collapsed). - - ))} -
+ +
+ {previewFields.map(([key, val]) => ( + // Inside the explorer (hideExplorerCtas) this card IS the + // canonical record view, so top-level fields start expanded — + // the visitor came here to read the record, not to click each + // object/array chip open. Nested rows stay collapsed so deep + // structures don't unfurl all at once. On universal-link pages + // the preview stays compact (everything collapsed). + + ))} +
+
{/* Single CTA: navigates into the explorer's record page, which shows the full record JSON (linkified), backlinks, identity, @@ -325,6 +354,7 @@ function FieldRow({ value, isLast, defaultOpen = false, + suppressBlobImage = false, }: { label: string; value: unknown; @@ -335,10 +365,24 @@ function FieldRow({ /** Initial expansion state. Top-level rows in the explorer pass true so * the record opens already unfurled; nested rows default to collapsed. */ defaultOpen?: boolean; + /** When true, don't render a blob thumbnail for this row — set by the parent + * when a sibling field already previews the same image as a direct URL (e.g. + * arena mirror's `image.src` next to `image.blob`), so we show it once. */ + suppressBlobImage?: boolean; }) { const [open, setOpen] = useState(defaultOpen); + const { pds, did } = useContext(RecordImageContext); const expandable = isExpandable(value); const children = expandable ? childEntries(value) : []; + // A sibling direct-URL image means this object's blob is (almost always) the + // same asset; let the URL row own the single preview. + const childrenHaveImageUrl = children.some( + ([, v]) => imageUrlFromValue(v) != null, + ); + + // Blob-backed image preview (needs the repo coordinates to build getBlob). + const blob = suppressBlobImage ? null : imageBlobFromValue(value); + const blobSrc = blob && pds && did ? getBlobUrl(pds, did, blob.cid) : null; return (
+ {blobSrc && ( +
+ +
+ )} {expandable && (
))}
@@ -449,6 +499,21 @@ function FieldPrimitive({ value }: { value: unknown }) { if (href && value.startsWith('at://')) { return ; } + // Direct image URL: keep the copyable text and preview it beneath. The + // URL still renders as a plain external string (like other non-AT URLs); + // the thumbnail is the added affordance. + const imageUrl = imageUrlFromValue(value); + if (imageUrl) { + const safe = sanitizeUrl(imageUrl); + if (safe !== '#') { + return ( + <> + + + + ); + } + } } return ; } diff --git a/src/components/explore/RecordExplorer.tsx b/src/components/explore/RecordExplorer.tsx index 048b5d2..dc14d14 100644 --- a/src/components/explore/RecordExplorer.tsx +++ b/src/components/explore/RecordExplorer.tsx @@ -300,6 +300,7 @@ export default function RecordExplorer({ repo, collection, rkey }: Props) { collection={collection} handle={identity.handle || identity.did} rkey={decodedRkey} + pds={identity.pds} hideExplorerCtas /> )} diff --git a/src/utils/recordImages.ts b/src/utils/recordImages.ts new file mode 100644 index 0000000..b931554 --- /dev/null +++ b/src/utils/recordImages.ts @@ -0,0 +1,79 @@ +/** + * Image detection for the rich JSON record preview. + * + * Records reference images two ways, and both should render a thumbnail in the + * structured field view: + * + * 1. A direct HTTP(S) URL in a string field (e.g. arena mirror's `image.src`, + * an external `thumbnail`, etc.). Rendered straight from the URL. + * 2. An AT Protocol blob with an image `mimeType` (e.g. a profile avatar, or + * arena mirror's `image.blob`). Rendered via `com.atproto.sync.getBlob` + * against the owning PDS, so it needs the repo's DID + PDS endpoint. + * + * These are pure helpers (no React, no fetch) so both the preview component and + * any future caller share one definition of "is this field an image". + */ + +/** Path extensions we're willing to render inline as an image. */ +const IMAGE_EXT_RE = + /\.(jpe?g|png|gif|webp|avif|svg|bmp|ico|apng|jfif|heic|heif|tiff?)$/i; + +/** + * If `value` is a string that points at a renderable image over HTTP(S), + * return the URL; otherwise null. The extension is checked against the URL's + * pathname only, so query strings (arena appends `?`) and fragments + * don't defeat the match. The raw URL is returned un-sanitized — callers pass + * it through `sanitizeUrl` before putting it in the DOM. + */ +export function imageUrlFromValue(value: unknown): string | null { + if (typeof value !== 'string') return null; + const s = value.trim(); + if (!/^https?:\/\//i.test(s)) return null; + let pathname: string; + try { + pathname = new URL(s).pathname; + } catch { + return null; + } + return IMAGE_EXT_RE.test(pathname) ? s : null; +} + +export type ImageBlobRef = { cid: string; mimeType: string }; + +/** + * If `value` is an AT Protocol blob describing an image, return its CID + + * mimeType; otherwise null. Handles both the current blob shape + * (`{ $type: 'blob', ref: { $link }, mimeType }`) and the legacy inline-CID + * shape (`{ cid, mimeType }`). Non-image blobs (video, etc.) return null so we + * never try to render them as ``. + */ +export function imageBlobFromValue(value: unknown): ImageBlobRef | null { + if (!value || typeof value !== 'object') return null; + const obj = value as Record; + const mimeType = typeof obj.mimeType === 'string' ? obj.mimeType : null; + if (!mimeType || !mimeType.startsWith('image/')) return null; + + const ref = obj.ref; + if (ref && typeof ref === 'object') { + const link = (ref as Record)['$link']; + if (typeof link === 'string' && link) return { cid: link, mimeType }; + } + // Legacy shape: the CID sits directly on the blob. + if (typeof obj.cid === 'string' && obj.cid) return { cid: obj.cid, mimeType }; + return null; +} + +/** + * Build the public `com.atproto.sync.getBlob` URL that serves a blob's bytes + * straight from the owning PDS. + */ +export function getBlobUrl(pds: string, did: string, cid: string): string { + const params = new URLSearchParams({ did, cid }); + return `${pds.replace(/\/$/, '')}/xrpc/com.atproto.sync.getBlob?${params}`; +} + +/** Pull the DID authority out of an `at://did:.../…` URI (null if not a DID). */ +export function didFromAtUri(uri: string): string | null { + const match = /^at:\/\/(did:[^/]+)/.exec(uri); + return match ? match[1] : null; +} -- 2.51.2