diff --git a/src/__tests__/scan.test.js b/src/__tests__/scan.test.js
index bd0bde6..ed483b6 100644
--- a/src/__tests__/scan.test.js
+++ b/src/__tests__/scan.test.js
@@ -202,4 +202,36 @@ describe('runScan — fail-closed invariants', () => {
expect(result.trustworthy).toBe(true);
expect(result.held).toEqual([BLOB1, BLOB2].sort());
});
+
+ test('onCollection fires once per collection (not once per batch)', async () => {
+ const api = makeApi({
+ held: [BLOB1],
+ collections: [
+ {
+ nsid: 'app.bsky.feed.post',
+ records: [
+ makeRecord('app.bsky.feed.post', { text: 'a', embed: { images: [{ image: { $type: 'blob', ref: { $link: BLOB1 } } }] } }),
+ makeRecord('app.bsky.feed.post', { text: 'b', embed: { images: [{ image: { $type: 'blob', ref: { $link: BLOB1 } } }] } }),
+ makeRecord('app.bsky.feed.post', { text: 'c', embed: { images: [{ image: { $type: 'blob', ref: { $link: BLOB1 } } }] } }),
+ ],
+ },
+ ],
+ });
+ // Force two pages of records for the single collection
+ const orig = api.listRecords;
+ api.listRecords = async (params) => {
+ const res = await orig({ ...params, limit: 2 });
+ return res;
+ };
+ const emitted = [];
+ await runScan({
+ did: DID,
+ ...api,
+ onProgress: {
+ onCollection: (nsid, records, blobRefs) => emitted.push({ nsid, records, blobRefs }),
+ },
+ });
+ expect(emitted).toHaveLength(1);
+ expect(emitted[0]).toEqual({ nsid: 'app.bsky.feed.post', records: 3, blobRefs: 3 });
+ });
});
diff --git a/src/components/OrphanGrid.js b/src/components/OrphanGrid.js
index 71e6c4c..57aca65 100644
--- a/src/components/OrphanGrid.js
+++ b/src/components/OrphanGrid.js
@@ -1,82 +1,75 @@
// src/components/OrphanGrid.js — orphan thumbnails, CIDs, MIME, size, selection
//
-// Metadata for orphans is NOT in any record (that's why they're orphans).
-// Per the plan (§4) it is recovered via getBlob (Content-Type / Content-Length)
-// once per CID and cached. Image blobs also get an object URL for the thumb.
+// Rendering follows atproto.at's blob page: thumbnails come from Bluesky's
+// image CDN (https://cdn.bsky.app/img/feed_thumbnail/plain/{did}/{cid}@jpeg),
+// no auth, no fetch — a plain
with an onError fallback to the generic
+// icon for non-image blobs (video, captions, PDFs, …).
+//
+// Download fetches the blob through the authenticated agent (same-origin blob
+// URL so the `download` attribute is honored cross-browser), falling back to
+// a direct PDS getBlob navigation if the fetch fails.
-import React, { useState, useEffect } from 'react';
+import React, { useState, useCallback } from 'react';
import { downloadBlob } from '../lib/backup';
const CID_PREFIX_LEN = 16;
-// Don't hold object URLs for blobs larger than this (memory).
-const THUMB_MAX_BYTES = 5 * 1024 * 1024;
+const THUMB_CDN = (did, cid) =>
+ `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${cid}@jpeg`;
-// module-level cache: did+cid -> {mimeType, size, url|null, loading, failed}
-const metaCache = new Map();
+const BlobThumb = ({ did, cid }) => {
+ const [failed, setFailed] = useState(false);
-const useBlobMeta = ({ did, cid, agent }) => {
- const [meta, setMeta] = useState(() => metaCache.get(`${did}:${cid}`) || null);
+ if (failed) {
+ return
;
+ }
+ return (
+
setFailed(true)}
+ />
+ );
+};
- useEffect(() => {
- if (!agent || !did || !cid) return undefined;
- const key = `${did}:${cid}`;
- if (metaCache.has(key)) {
- setMeta(metaCache.get(key));
- return undefined;
- }
- let cancelled = false;
- metaCache.set(key, { loading: true, failed: false, mimeType: null, size: null, url: null });
- (async () => {
+const OrphanCard = ({ cid, did, pdsUrl, agent, selected, onToggle, copiedCid, onCopy }) => {
+ const [downloading, setDownloading] = useState(false);
+ const directUrl = `${pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`;
+
+ const handleDownload = useCallback(
+ async (e) => {
+ e.preventDefault();
+ if (!agent) {
+ window.open(directUrl, '_blank', 'noopener');
+ return;
+ }
+ setDownloading(true);
try {
const res = await agent.api.com.atproto.sync.getBlob({ did, cid });
- const headers = res?.headers || {};
- const get = (n) => (typeof headers.get === 'function' ? headers.get(n) : headers[n]);
- const mimeType = get('content-type') || 'application/octet-stream';
- const size = Number.parseInt(get('content-length') || '0', 10) || 0;
- let url = null;
const data = res?.data;
+ let blob;
if (data instanceof Blob) {
- if (data.size <= THUMB_MAX_BYTES && mimeType.startsWith('image/')) {
- url = URL.createObjectURL(data);
- }
+ blob = data;
} else if (data instanceof ArrayBuffer) {
- if (data.byteLength <= THUMB_MAX_BYTES && mimeType.startsWith('image/')) {
- url = URL.createObjectURL(new Blob([data], { type: mimeType }));
- }
- }
- const entry = { loading: false, failed: false, mimeType, size, url };
- if (!cancelled) {
- metaCache.set(key, entry);
- setMeta(entry);
+ blob = new Blob([data]);
+ } else if (data && typeof data.byteLength === 'number') {
+ // Uint8Array (what getBlob actually returns)
+ blob = new Blob([data]);
+ } else {
+ throw new Error('unexpected getBlob response');
}
+ downloadBlob(blob, `${cid}.bin`);
} catch (err) {
- const entry = { loading: false, failed: true, mimeType: null, size: null, url: null };
- if (!cancelled) {
- metaCache.set(key, entry);
- setMeta(entry);
- }
+ console.error(`Download failed for ${cid}:`, err);
+ window.open(directUrl, '_blank', 'noopener');
+ } finally {
+ setDownloading(false);
}
- })();
- return () => {
- cancelled = true;
- };
- }, [did, cid, agent]);
-
- return meta;
-};
-
-const BlobThumb = ({ meta }) => {
- if (!meta || meta.loading) {
- return ;
- }
- if (meta.failed || !meta.url) {
- return ;
- }
- return
;
-};
+ },
+ [agent, did, cid, directUrl]
+ );
-const OrphanCard = ({ cid, pdsUrl, did, agent, selected, onToggle, copiedCid, onCopy, onDownload }) => {
- const meta = useBlobMeta({ did, cid, agent });
return (
-
+
-
- {meta?.mimeType || '…'}
- {meta?.size ? {(meta.size / 1024).toFixed(1)} KiB : null}
-
-
+
+ {downloading ? 'Downloading…' : 'Download'}
+
);
};
@@ -115,18 +110,6 @@ const OrphanGrid = ({ orphans, pdsUrl, did, agent, selected, allSelected, onTogg
}
};
- const downloadOne = async (cid, mimeType) => {
- try {
- const res = await agent.api.com.atproto.sync.getBlob({ did, cid });
- const data = res?.data;
- const blob =
- data instanceof Blob ? data : new Blob([data], { type: mimeType || 'application/octet-stream' });
- downloadBlob(blob, `${cid}.bin`);
- } catch (err) {
- console.error(`Download failed for ${cid}:`, err);
- }
- };
-
return (
@@ -141,14 +124,13 @@ const OrphanGrid = ({ orphans, pdsUrl, did, agent, selected, allSelected, onTogg
onToggleOne(cid)}
copiedCid={copiedCid}
onCopy={copyCid}
- onDownload={downloadOne}
/>
))}
diff --git a/src/lib/scan.js b/src/lib/scan.js
index 7c70b88..a1e535f 100644
--- a/src/lib/scan.js
+++ b/src/lib/scan.js
@@ -125,7 +125,6 @@ export async function runScan({
col.blobRefs += 1;
}
}
- onProgress.onCollection?.(nsid, col.records, col.blobRefs);
onProgress.onRecord?.(col.records);
const next = res?.data?.cursor;
if (next !== undefined && next !== null && next !== '') {
@@ -138,6 +137,9 @@ export async function runScan({
paging = false;
}
}
+ // Emit the collection row only once the whole collection has finished
+ // enumerating — not once per batch.
+ onProgress.onCollection?.(nsid, col.records, col.blobRefs);
}
// 4. Diff
diff --git a/src/pages/Sifter.css b/src/pages/Sifter.css
index 6d79062..d6014d0 100644
--- a/src/pages/Sifter.css
+++ b/src/pages/Sifter.css
@@ -196,12 +196,24 @@
}
.download-one {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ text-decoration: none;
background: var(--navbar-bg);
color: var(--text);
border: 2px solid var(--card-border);
+ border-radius: 6px;
min-height: 44px;
padding: 8px 12px;
font-size: 0.9rem;
+ font-weight: 700;
+ font-family: inherit;
+ cursor: pointer;
+}
+
+.download-one:hover {
+ border-color: var(--button-bg);
}
.select-all {