aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js102
1 files changed, 94 insertions, 8 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index e3cdc8d..fb9a9d6 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -1019,6 +1019,68 @@ export function messagesFeature(ctx) {
}
}
}
+ // ---- a Blossom blob gone from its host, found on another ---------------
+ // A picture named by its sha256 (…/<64 hex>.jpg) is a Blossom blob: any
+ // server holding the same bytes serves them under the same name. When the
+ // host in the URL no longer has it — Karnage's face and banner on
+ // media.21media.to answered "blob not found" (2026-11-02) while
+ // blossom.primal.net, the server he publishes in his kind 10063, still had
+ // both — the picture is looked for on the servers the person published
+ // and then on the public ones we know, and the first that answers stands
+ // in for it: the thumbnail is made from it, the big face and the banner
+ // paint from it. Remembered on the device, so the search runs once.
+ const BLOB_RE = /^(https?:\/\/[^/?#]+)(?:\/[^?#]*)?\/([0-9a-f]{64})(\.[a-z0-9]{2,5})?(?:[?#].*)?$/i;
+ const BLOB_ALT_HOSTS = ['https://blossom.primal.net', 'https://blossom.ditto.pub', 'https://nostr.download',
+ 'https://blossom.nostr.build', 'https://blossom.band', 'https://cdn.satellite.earth'];
+ const BLOB_ALT_KEY = 'btc-wallet-blob-alts';
+ const blobAlts = new Map(); // url -> the copy that answered, or null when none did
+ (() => { try { for (const [k, v] of Object.entries(JSON.parse(localStorage.getItem(BLOB_ALT_KEY) || '{}'))) if (typeof v === 'string') blobAlts.set(k, v); } catch {} })();
+ const saveBlobAlts = () => {
+ try {
+ const found = [...blobAlts].filter(([, v]) => v).slice(-200);
+ localStorage.setItem(BLOB_ALT_KEY, JSON.stringify(Object.fromEntries(found)));
+ } catch {}
+ };
+ const blobProbing = new Map();
+ const blobAltOf = (url) => (url && blobAlts.get(url)) || null;
+ // the servers a person publishes for their files (BUD-03), asked only when
+ // one of their pictures has gone missing
+ const blobServersOf = async (pk) => {
+ try {
+ const evs = await queryOn(PROFILE_RELAYS, { kinds: [10063], authors: [pk], limit: 1 }, 4000);
+ const ev = (evs || []).sort((a, b) => b.created_at - a.created_at)[0];
+ return ev ? ev.tags.filter((x) => x[0] === 'server' && /^(https:\/\/|http:\/\/localhost(:\d+)?\/)/i.test(x[1] || ''))
+ .map((x) => x[1].replace(/\/+$/, '')) : [];
+ } catch { return []; }
+ };
+ // does an <img> load from here? (no CORS needed to find out)
+ const imageAnswers = (url, ms = 8000) => new Promise((resolve) => {
+ const im = new Image();
+ const timer = setTimeout(() => { im.onload = im.onerror = null; im.src = ''; resolve(false); }, ms);
+ im.onload = () => { clearTimeout(timer); resolve(true); };
+ im.onerror = () => { clearTimeout(timer); resolve(false); };
+ im.src = url;
+ });
+ function findBlobAlt(url, pk) {
+ if (blobAlts.has(url)) return Promise.resolve(blobAlts.get(url));
+ if (blobProbing.has(url)) return blobProbing.get(url);
+ const m = BLOB_RE.exec(url || '');
+ if (!m || typeof Image === 'undefined') return Promise.resolve(null);
+ const task = (async () => {
+ const origin = m[1].toLowerCase(), name = m[2] + (m[3] || '');
+ const hosts = [...new Set([...(pk ? await blobServersOf(pk) : []), ...BLOB_ALT_HOSTS])]
+ .filter((h) => h.toLowerCase() !== origin);
+ // asked together (a host that hangs mustn't hold the rest), the
+ // person's own servers preferred among those that answer
+ const answers = await Promise.all(hosts.map((h) => imageAnswers(h + '/' + name).then((ok) => (ok ? h + '/' + name : null))));
+ const alt = answers.find(Boolean) || null;
+ blobAlts.set(url, alt);
+ if (alt) saveBlobAlts();
+ return alt;
+ })().finally(() => blobProbing.delete(url));
+ blobProbing.set(url, task);
+ return task;
+ }
function makeThumb(pk, p) {
if (!p || !p.picture || typeof document === 'undefined') return;
if (noCorsHost(p.picture)) return; // a host that won't hand over bytes; the <img> shows it anyway
@@ -1075,8 +1137,20 @@ export function messagesFeature(ctx) {
// the browser's cache is welcome: the face was just painted from this
// very URL, and 'reload' re-downloaded every original (38 MB in one
// scroll) — a stale copy still makes a fine thumbnail
- const res = await corsFetch(url, AbortSignal.timeout(THUMB_SLOW));
- if (!res.ok) throw new Error('http ' + res.status);
+ let res = null;
+ try { res = await corsFetch(url, AbortSignal.timeout(THUMB_SLOW)); } catch (e) {
+ if (e && e.corsRefused) noteNoCors(url);
+ if (!BLOB_RE.test(url)) throw e;
+ }
+ // the host no longer has it (or won't answer): the same blob on
+ // another server is as good — and the face it paints is this one
+ if (!res || !res.ok) {
+ const alt = BLOB_RE.test(url) ? await findBlobAlt(url, pk) : null;
+ if (!alt) throw new Error('http ' + (res ? res.status : 'none'));
+ res = await corsFetch(alt, AbortSignal.timeout(THUMB_SLOW));
+ if (!res.ok) throw new Error('http ' + res.status);
+ scheduleRepaint(); // the big face, where one is up, can paint from the copy now
+ }
bmp = await createImageBitmap(await res.blob());
// centre-crop to a square, the way background-size:cover paints it
const side = Math.min(bmp.width, bmp.height);
@@ -3857,8 +3931,9 @@ export function messagesFeature(ctx) {
const punk = localPunk(p.picture, big);
if (punk) return `url(${JSON.stringify(punk)})`;
const local = p.thumb && p.thumbFor === p.picture ? p.thumb : null;
- if (!local) return `url(${JSON.stringify(p.picture)})`;
- return (big ? `url(${JSON.stringify(p.picture)}),` : '') + `url(${JSON.stringify(local)})`;
+ const pic = blobAltOf(p.picture) || p.picture; // the same blob elsewhere, when the host lost it
+ if (!local) return `url(${JSON.stringify(pic)})`;
+ return (big ? `url(${JSON.stringify(pic)}),` : '') + `url(${JSON.stringify(local)})`;
}
const FACE_WAIT_MS = 1000;
@@ -4064,7 +4139,17 @@ export function messagesFeature(ctx) {
};
function warmBanner(pk) {
const b = bannerOf(pk);
- if (b && !mediaReady.has(b)) return warmMedia(b, { thumb: true }).then(() => scheduleRepaint());
+ if (!b) return;
+ const shown = blobAltOf(b) || b;
+ if (mediaReady.has(shown)) return;
+ return warmMedia(shown, { thumb: true }).then(async () => {
+ // the host lost it: the same blob on another server paints instead
+ if (!mediaReady.has(shown) && shown === b && BLOB_RE.test(b)) {
+ const alt = await findBlobAlt(b, pk);
+ if (alt) await warmMedia(alt, { thumb: true });
+ }
+ scheduleRepaint();
+ });
}
function prefetchProfilePage(pk) {
if (!pk || prefetched.has(pk)) return;
@@ -7100,7 +7185,7 @@ export function messagesFeature(ctx) {
return urls;
}
const avatarUrl = (p, pk) => p?.picture
- ? localPunk(p.picture) || (p.thumbFor === p.picture && p.thumb) || p.picture
+ ? localPunk(p.picture) || (p.thumbFor === p.picture && p.thumb) || blobAltOf(p.picture) || p.picture
: punkSmallUrl(pk);
async function warmAvatar(pk, deadline) {
let p = liveProfileOf(pk);
@@ -13329,7 +13414,8 @@ export function messagesFeature(ctx) {
// doesn't drop by the height of a cover photo when it arrives
: urlish(lp.banner);
const draftPic = draft && urlish(draft.picture);
- if (bannerUrl && !draft && !mediaReady.has(bannerUrl)) warmBanner(pk);
+ const bannerShown = bannerUrl && !draft ? blobAltOf(bannerUrl) || bannerUrl : bannerUrl;
+ if (bannerUrl && !draft && !mediaReady.has(bannerShown)) warmBanner(pk);
return h('div', { class: 'col', style: 'gap:16px' },
// full header on profiles too — losing the search button here made
// finding the NEXT person a trek back home
@@ -13338,7 +13424,7 @@ export function messagesFeature(ctx) {
t(full || lp.eventAt || lp.name || lp.picture ? 'profFetchFailed' : 'profNotFound'),
h('button', { class: 'btn-sm', onClick: () => { fetchFullProfile(pk); render(); } }, t('retry'))) : null,
h('div', { class: 'card col', style: 'gap:12px' },
- bannerUrl ? h('div', { class: 'profile-banner', style: `background-image:url(${JSON.stringify(draft ? bannerUrl : mediaSrc(bannerUrl, mediaReady.get(bannerUrl)))})` }) : null,
+ bannerUrl ? h('div', { class: 'profile-banner', style: `background-image:url(${JSON.stringify(draft ? bannerUrl : mediaSrc(bannerShown, mediaReady.get(bannerShown)))})` }) : null,
h('div', { class: 'row gap6', style: 'align-items:center' },
draftPic
? h('div', { class: 'chat-avatar profile-avatar ava-img', style: `background-image:url(${JSON.stringify(draftPic)})` })