aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js152
-rw-r--r--src/style.css3
-rw-r--r--tools/feed-video-aspect-test.js17
3 files changed, 156 insertions, 16 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 2b8365b..10c9d56 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -713,12 +713,13 @@ export function messagesFeature(ctx) {
const THUMB_VERSION = 1;
const thumbing = new Set();
const thumbQueue = new Map();
- // Hosts that serve pictures without CORS headers (primal's blossom,
- // cdn.nostrcheck.me, most personal sites): the browser refuses the bytes to
+ // Hosts that serve pictures without CORS headers (cdn.nostrcheck.me, most
+ // personal sites): the browser refuses the bytes to
// a fetch and prints a red line per face while the <img> paints fine. No
// thumbnail is to be had there, so after a second quick refusal the host
// is left alone for a week rather than asked about every new face.
- const NOCORS_KEY = 'coinos-thumb-nocors';
+ // v2: the first list was mostly wrong (primal's CDN, coinos.io) — see corsFetch
+ const NOCORS_KEY = 'coinos-thumb-nocors-v2';
const NOCORS_MS = 7 * 24 * 3600_000;
let noCors = null; // host -> { n, until }
const noCorsNow = () => { if (!noCors) { try { noCors = JSON.parse(localStorage.getItem(NOCORS_KEY) || '{}'); } catch { noCors = {}; } } return noCors; };
@@ -731,6 +732,22 @@ export function messagesFeature(ctx) {
if (Object.keys(m).length > 200) delete m[Object.keys(m)[0]];
try { localStorage.setItem(NOCORS_KEY, JSON.stringify(m)); } catch {}
};
+ // The bytes of a picture, readable by script. The HTTP cache comes first:
+ // the face was usually just painted from this URL. But a CDN that sends
+ // its CORS header only to requests carrying an Origin, and caches the
+ // plain answer without `Vary: Origin` (primal's r2a.primal.net does), hands
+ // the cached copy — header-less — to our CORS fetch, which then fails as if
+ // the host refused. That wrote primal and coinos.io off as "no CORS" and
+ // left every picture hosted there painting from the full-size original.
+ // So a refusal is retried once past the cache before anyone is blamed.
+ async function corsFetch(url, signal) {
+ try {
+ return await fetch(url, { mode: 'cors', signal });
+ } catch (e) {
+ if (!e || e.name !== 'TypeError') throw e;
+ return fetch(url, { mode: 'cors', cache: 'reload', signal });
+ }
+ }
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
@@ -788,7 +805,7 @@ 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 fetch(url, { mode: 'cors', signal: AbortSignal.timeout(THUMB_SLOW) });
+ const res = await corsFetch(url, AbortSignal.timeout(THUMB_SLOW));
if (!res.ok) throw new Error('http ' + res.status);
bmp = await createImageBitmap(await res.blob());
// centre-crop to a square, the way background-size:cover paints it
@@ -3096,6 +3113,15 @@ export function messagesFeature(ctx) {
const prefetched = new Set();
const prefetchQueue = [];
let prefetching = 0;
+ const bannerOf = (pk) => {
+ const full = fullProfiles.get(pk), lp = profiles.get(pk) || {};
+ const b = (full && full.banner) || lp.banner;
+ return typeof b === 'string' && /^https?:\/\//i.test(b.trim()) ? b.trim() : null;
+ };
+ function warmBanner(pk) {
+ const b = bannerOf(pk);
+ if (b && !mediaReady.has(b)) return warmMedia(b, { thumb: true }).then(() => scheduleRepaint());
+ }
function prefetchProfilePage(pk) {
if (!pk || prefetched.has(pk)) return;
prefetched.add(pk);
@@ -3107,7 +3133,8 @@ export function messagesFeature(ctx) {
const pk = prefetchQueue.shift();
prefetching++;
Promise.allSettled([
- Promise.resolve(fetchFullProfile(pk)),
+ // the cover photo as a column-sized copy, ready before the page opens
+ Promise.resolve(fetchFullProfile(pk)).then(() => warmBanner(pk)),
Promise.resolve(notesFor(pk)),
]).finally(() => { prefetching--; drainPrefetch(); });
}
@@ -4962,9 +4989,97 @@ export function messagesFeature(ctx) {
const READY_MS = 8000;
const mediaReady = new Map(); // URL -> decoded dimensions
const mediaWarming = new Map();
- const warmMedia = (url) => {
+ // A post's photo is often the camera's original — 3024px wide, twelve
+ // megapixels — painted into a column a sixth of that. Chrome decodes it
+ // again whenever the page that holds it is rebuilt (its decoded copy is
+ // long evicted by then), and until it has, the picture is a blank box: the
+ // feed came back from a profile with every post's text in place and its
+ // photo 200ms behind. So a big picture is painted from a copy the size the
+ // column can actually show, made once and kept on disk; it decodes in a few
+ // milliseconds, inside the same frame as the text. The original is still
+ // what the lightbox opens. A host that won't hand over its bytes (no CORS)
+ // keeps the old way.
+ const MEDIA_THUMB_W = 1100; // the 560px column at 2x
+ const MEDIA_THUMB_CACHE = 'coinos-media-thumbs-v1';
+ const MEDIA_THUMB_MAX = 400; // copies kept on disk, oldest out first
+ const thumbStore = () => (typeof caches !== 'undefined' ? caches.open(MEDIA_THUMB_CACHE) : Promise.reject(new Error('no cache')));
+ async function storedMediaThumb(url) {
+ try {
+ const r = await (await thumbStore()).match(url);
+ if (!r) return null;
+ const dims = JSON.parse(r.headers.get('x-dims') || 'null');
+ if (!dims || !dims.width) return null;
+ const src = r.headers.get('x-original') === '1' ? null : URL.createObjectURL(await r.blob());
+ return { ...dims, thumb: src };
+ } catch { return null; }
+ }
+ async function makeMediaThumb(url) {
+ if (noCorsHost(url) || typeof createImageBitmap === 'undefined' || typeof OffscreenCanvas === 'undefined') return null;
+ const t0 = Date.now();
+ let blob;
+ try {
+ const res = await corsFetch(url, AbortSignal.timeout(READY_MS));
+ if (!res.ok) return null;
+ blob = await res.blob();
+ } catch (e) {
+ // a quick TypeError is the CORS refusal (a dead host takes longer)
+ if (e && e.name === 'TypeError' && Date.now() - t0 < 4000) noteNoCors(url);
+ return null;
+ }
+ let bmp;
+ try { bmp = await createImageBitmap(blob); } catch { return null; }
+ const dims = { width: bmp.width, height: bmp.height };
+ // small enough already, or animated (a GIF's copy would be one frame):
+ // remember the shape and paint the original
+ const keep = bmp.width <= MEDIA_THUMB_W * 1.25 || /gif/i.test(blob.type);
+ let out = null;
+ if (!keep) {
+ try {
+ const h = Math.max(1, Math.round(bmp.height * MEDIA_THUMB_W / bmp.width));
+ const c = new OffscreenCanvas(MEDIA_THUMB_W, h);
+ const g = c.getContext('2d');
+ g.imageSmoothingQuality = 'high';
+ g.drawImage(bmp, 0, 0, MEDIA_THUMB_W, h);
+ out = await c.convertToBlob({ type: 'image/webp', quality: 0.82 });
+ if (!/webp/.test(out.type)) out = await c.convertToBlob({ type: 'image/jpeg', quality: 0.82 });
+ } catch { out = null; }
+ }
+ try { bmp.close(); } catch {}
+ try {
+ const store = await thumbStore();
+ await store.put(url, new Response(out || new Blob(), { headers: { 'x-dims': JSON.stringify(dims), 'x-original': out ? '0' : '1' } }));
+ const keys = await store.keys();
+ for (const k of keys.slice(0, Math.max(0, keys.length - MEDIA_THUMB_MAX))) store.delete(k);
+ } catch {}
+ return { ...dims, thumb: out ? URL.createObjectURL(out) : null };
+ }
+ // Decode a copy ahead, so the <img> that paints it finds it ready.
+ const decodeAhead = (src) => new Promise((resolve) => {
+ const img = new Image();
+ img.onload = () => (img.decode ? img.decode() : Promise.resolve()).then(resolve, resolve);
+ img.onerror = resolve;
+ img.src = src;
+ });
+ const warmMedia = (url, { thumb = false } = {}) => {
if (!url || mediaReady.has(url) || typeof Image === 'undefined') return Promise.resolve();
if (mediaWarming.has(url)) return mediaWarming.get(url);
+ if (thumb && /^https?:/i.test(url)) {
+ const task = (async () => {
+ const got = (await storedMediaThumb(url)) || (await makeMediaThumb(url).catch(() => null));
+ if (got) {
+ if (got.thumb) await decodeAhead(got.thumb);
+ mediaReady.set(url, got);
+ return;
+ }
+ await plainWarm(url);
+ })().finally(() => mediaWarming.delete(url));
+ mediaWarming.set(url, task);
+ return task;
+ }
+ return plainWarm(url);
+ };
+ const plainWarm = (url) => {
+ if (mediaWarming.has(url) && mediaWarming.get(url).plain) return mediaWarming.get(url);
const task = new Promise((resolve) => {
const img = new Image();
const timer = setTimeout(() => done(false), READY_MS);
@@ -4977,10 +5092,14 @@ export function messagesFeature(ctx) {
img.onload = () => { (img.decode ? img.decode() : Promise.resolve()).then(() => done(true), () => done(false)); };
img.onerror = () => done(false);
img.src = url;
- }).finally(() => mediaWarming.delete(url));
- mediaWarming.set(url, task);
+ }).finally(() => { if (mediaWarming.get(url) === task) mediaWarming.delete(url); });
+ task.plain = true;
+ if (!mediaWarming.has(url)) mediaWarming.set(url, task);
return task;
};
+ // What an <img> for this picture should load: the column-sized copy when
+ // there is one.
+ const mediaSrc = (url, d = feedMedia(url)) => (d && d.thumb) || url;
// A video's shape, so the feed paints its box at the clip's own aspect
// instead of a 16:9 frame with black bands either side of a portrait clip.
// The post's imeta `dim` says it for free (Amethyst, Primal and others
@@ -5140,7 +5259,7 @@ export function messagesFeature(ctx) {
if (p?.picture) makeThumb(pk, p);
}
async function noteReady(ev, deadline = Date.now() + READY_MS, depth = 0) {
- const tasks = [warmAvatar(ev.pubkey, deadline), ...noteMediaUrls(ev.content).map(warmMedia),
+ const tasks = [warmAvatar(ev.pubkey, deadline), ...noteMediaUrls(ev.content).map((u) => warmMedia(u, { thumb: true })),
...noteVideoUrls(ev.content).map((u) => warmVideo(u, imetaDim(ev, u))),
...notePreviewUrls(ev.content).map(warmPreview),
...[...emojiTagMap(ev.tags).values()].map(warmMedia)];
@@ -6036,8 +6155,10 @@ export function messagesFeature(ctx) {
if (feedPaint && !size) return h('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, label || url);
// tap it to see it properly — a 320px-tall crop of someone's
// photograph is a thumbnail, not the picture they posted
+ const known = size || mediaReady.get(url);
return h('img', {
- src: url, class: 'note-img clickable', loading: feedPaint ? 'eager' : 'lazy', alt: label || '',
+ src: mediaSrc(url, known), class: 'note-img clickable', loading: feedPaint || known ? 'eager' : 'lazy', alt: label || '',
+ decoding: known && known.thumb ? 'sync' : undefined, // a few-ms decode: paint it with the page
width: size?.width, height: size?.height,
style: size ? 'height:auto;aspect-ratio:' + size.width + '/' + size.height : undefined,
onClick: (e) => { if (!onPicture(e)) return; e.stopPropagation(); ctx.openImage && ctx.openImage(url); },
@@ -8115,6 +8236,7 @@ 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);
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
@@ -8123,7 +8245,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(bannerUrl)})` }) : null,
+ bannerUrl ? h('div', { class: 'profile-banner', style: `background-image:url(${JSON.stringify(draft ? bannerUrl : mediaSrc(bannerUrl, mediaReady.get(bannerUrl)))})` }) : 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)})` })
@@ -9376,10 +9498,10 @@ export function messagesFeature(ctx) {
h('div', { class: 'col grow', style: 'min-width:0;gap:1px' },
h('div', { class: 'row between' },
h('span', { class: 'chat-name' }, displayName(peer)),
- h('span', { class: 'chat-time thread-when' },
- timeLabel(last.rumor.created_at * 1000),
- unread ? h('i', { class: 'thread-dot' }) : null)),
- h('div', { class: 'muted small chat-preview' }, (last.mine ? t('msgYouPrefix') + ' ' : '') + (last.rumor.kind === 15 ? '📎 ' + t('msgPhoto') : last.rumor.content))))))
+ h('span', { class: 'chat-time thread-when' }, timeLabel(last.rumor.created_at * 1000))),
+ h('div', { class: 'muted small chat-preview' }, (last.mine ? t('msgYouPrefix') + ' ' : '') + (last.rumor.kind === 15 ? '📎 ' + t('msgPhoto') : last.rumor.content))),
+ // the row's own dot, centred and inset like the communities' below
+ unread ? h('i', { class: 'thread-dot' }) : null)))
: h('div', { class: 'muted small' }, t('msgNoDms')));
if (shownDms.length < dmRows.length)
kids.push(h('button', { class: 'linklike small', onClick: () => { ui.msgAllDms = true; render(); } },
diff --git a/src/style.css b/src/style.css
index 2addd9c..8918c37 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1255,7 +1255,8 @@ body::after {
.balance-switch:hover { color: var(--ink); border-color: var(--line-strong); }
.balance-switch svg { opacity: .75; }
/* the face's settings: a round icon button, no label */
-.balance-gear { width: 34px; height: 34px; padding: 0; justify-content: center; }
+.balance-gear { width: 34px; height: 34px; padding: 0; justify-content: center; background: none; border: 0; }
+.balance-gear:hover { background: none; color: var(--ink); }
/* Move-money row: the action labels are long ("Move to Spending"), so let the
row wrap to two full-width lines rather than crushing the amount field. */
.balance-move .input-group { flex-wrap: wrap; }
diff --git a/tools/feed-video-aspect-test.js b/tools/feed-video-aspect-test.js
index 6f646cb..2a678bd 100644
--- a/tools/feed-video-aspect-test.js
+++ b/tools/feed-video-aspect-test.js
@@ -2,6 +2,7 @@
// bands either side: from the post's imeta `dim` when it says, else from the
// clip's metadata fetched before the row is admitted. A clip with no known
// shape still gets the 16:9 box.
+// Photos too: a big one paints from a column-sized copy (see makeMediaThumb).
// Run: bun tools/feed-video-aspect-test.js (CHROME=<path> to pick a browser)
import puppeteer from 'puppeteer-core';
import { cacheKeyFor } from '../src/wallet.js';
@@ -19,6 +20,12 @@ const clip = (name, size) => {
if (p.exitCode !== 0) throw new Error('ffmpeg failed');
};
clip('tall.mp4', '360x480'); clip('wide.mp4', '640x360'); clip('told.mp4', '360x640');
+// photos: a camera-sized one (painted from a column-sized copy) and a small one
+const photo = (name, size) => {
+ const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', `testsrc=size=${size}`, '-frames:v', '1', `${dir}/${name}`]);
+ if (p.exitCode !== 0) throw new Error('ffmpeg failed');
+};
+photo('big.jpg', '3000x2000'); photo('small.jpg', '800x600');
const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 0, idleTimeout: 30, fetch: async (req) => {
@@ -26,6 +33,7 @@ const server = Bun.serve({ port: 0, idleTimeout: 30, fetch: async (req) => {
// this one's metadata arrives too late for a probe: only the imeta dim can shape it
if (path === '/v/told.mp4') await sleep(6000);
if (path.startsWith('/v/')) return new Response(Bun.file(dir + path.slice(2)), { headers: { 'content-type': 'video/mp4' } });
+ if (path.startsWith('/i/')) return new Response(Bun.file(dir + path.slice(2)), { headers: { 'content-type': 'image/jpeg' } });
if (path === '/broken.mp4') return new Response('nope', { status: 404 });
if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
return new Response(html, { headers: { 'content-type': 'text/html' } });
@@ -40,6 +48,8 @@ const notes = [
// imeta says 9:16; the file is too, but the probe must not be needed
post(2, 'told clip ' + base + '/v/told.mp4', [['imeta', 'url ' + base + '/v/told.mp4', 'm video/mp4', 'dim 1080x1920']]),
post(3, 'broken clip ' + base + '/broken.mp4'),
+ post(4, 'big photo ' + base + '/i/big.jpg'),
+ post(5, 'small photo ' + base + '/i/small.jpg'),
];
const browser = await puppeteer.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
@@ -105,6 +115,13 @@ try {
check('a clip whose post gives imeta dim paints at that shape, without waiting on the file', Math.abs(ratio(b['told.mp4']) - 0.56) < 0.02 && /aspect-ratio:1080\/1920/.test(b['told.mp4'].style || ''), JSON.stringify(b['told.mp4']));
check('...full width, no taller than the screen', b['told.mp4'] && b['told.mp4'].w > 300 && b['told.mp4'].h <= 845, JSON.stringify(b['told.mp4']));
check('a clip that will not load keeps the 16:9 box', /aspect-ratio:16\/9/.test(b['broken.mp4']?.style || ''), JSON.stringify(b['broken.mp4']));
+ // a camera-sized photo paints from a column-sized copy, decoded with the page
+ const imgs = await page.evaluate(() => Object.fromEntries([...document.querySelectorAll('.notes-feed img.note-img')].map((i) => [
+ i.closest('.row').innerText.includes('big photo') ? 'big' : 'small', { src: i.src.slice(0, 5), nw: i.naturalWidth, dec: i.decoding, ar: i.style.aspectRatio }])));
+ check('a 3000px photo paints from an 1100px copy', imgs.big && imgs.big.src === 'blob:' && imgs.big.nw === 1100 && imgs.big.dec === 'sync', JSON.stringify(imgs.big));
+ check('...keeping the original shape', imgs.big && imgs.big.ar.replace(/\s/g, '') === '3000/2000', JSON.stringify(imgs.big));
+ check('a small photo is its own original', imgs.small && imgs.small.src === 'http:' && imgs.small.nw === 800, JSON.stringify(imgs.small));
+ check('the copy is kept on disk for the next visit', await page.evaluate(async () => !!(await (await caches.open('coinos-media-thumbs-v1')).match(location.origin + '/i/big.jpg'))));
await sleep(1500);
check('shapes are remembered on the device', await page.evaluate(() => (JSON.parse(localStorage.getItem('coinos-video-dims') || '[]')).length >= 3));
check('no page errors', errs.length === 0, errs.join('; ').slice(0, 300));