diff options
| -rw-r--r-- | src/app.js | 17 | ||||
| -rw-r--r-- | src/features/messages.js | 140 | ||||
| -rw-r--r-- | src/style.css | 20 | ||||
| -rw-r--r-- | tools/blob-alt-test.js | 92 | ||||
| -rw-r--r-- | tools/feed-bottom-nav-test.js | 2 | ||||
| -rw-r--r-- | tools/feed-fab-anim-test.js | 58 | ||||
| -rw-r--r-- | tools/feed-new-posts-test.js | 2 | ||||
| -rw-r--r-- | tools/feed-streaming-test.js | 2 |
8 files changed, 297 insertions, 36 deletions
@@ -648,12 +648,22 @@ function attachZoom(box, img, { wheel = true } = {}) { const mx = Math.max(0, (img.offsetWidth * s - window.innerWidth) / 2), my = Math.max(0, (img.offsetHeight * s - window.innerHeight) / 2); x = Math.min(mx, Math.max(-mx, x)); y = Math.min(my, Math.max(-my, y)); }; + // Chrome keeps a will-change: transform layer rastered at 1× and scales + // the bitmap: zoomed in, the raster's tiles show as a faint grid of + // seams across the picture ("very faint grid lines", Adam, 2026-11-02). + // The hint is on while a gesture moves the picture (no re-raster per + // frame) and off once it settles zoomed, so the picture is drawn again + // at its real scale: sharp, and seamless. + let settle = 0; const apply = (animate) => { + clearTimeout(settle); img.style.transition = animate ? 'transform .2s ease' : 'none'; img.style.transform = s === 1 && !x && !y ? '' : `translate(${x}px, ${y}px) scale(${s})`; img.style.cursor = s > 1 ? 'grab' : ''; // zoomed, the finger pans the picture; at 1× the touch is the page's (a pager's swipe) img.style.touchAction = s > 1 ? 'none' : ''; + img.style.willChange = 'transform'; + settle = setTimeout(() => { img.style.willChange = s > 1 ? 'auto' : ''; }, animate ? 250 : 120); }; // scale to ns keeping the picture point under (cx, cy) where it is const zoomAt = (ns, cx, cy) => { @@ -875,7 +885,12 @@ function renderInner() { const side = bottomNav && featScreen && !restorePending() && WIDE_MQ && WIDE_MQ.matches ? walletSide() : null; // a call, over whatever page is up (the messages feature's) const callOv = activeAccount() ? featureHook('callOverlay') : null; - morphChildren(root, [screen, footer(), ...(bottomNav ? [bottomNav] : []), ...(side ? [side] : []), ...(ui.lightbox ? [imageViewer()] : []), ...(callOv ? [callOv] : [])]); + // What a feature floats over its page (the feed's post button and + // new-posts pill): fixed-position nodes, so they live beside the page, + // not inside it — the page's entrance animation transforms it, and a + // transform makes fixed descendants position within the page. + const floaters = h('div', { class: 'page-floaters', style: 'display:contents' }, ...featureAll('pageFloaters')); + morphChildren(root, [screen, floaters, footer(), ...(bottomNav ? [bottomNav] : []), ...(side ? [side] : []), ...(ui.lightbox ? [imageViewer()] : []), ...(callOv ? [callOv] : [])]); promoteLazySrc(root); // sources on the nodes that actually made it into the page try { document.documentElement.classList.toggle('no-scroll', !!(ui.lightbox || ui.mediaPager)); } catch {} if (fpath) { diff --git a/src/features/messages.js b/src/features/messages.js index e3cdc8d..90bfaaf 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)})` }) @@ -16391,6 +16477,9 @@ export function messagesFeature(ctx) { window.addEventListener('popstate', () => setTimeout(syncMediaPager, 0)); } + // what the feed floats over the page (post button, new-posts pill): the + // shell takes them each render, see pageFloaters in the hooks + let pageFloaters = []; function feedView() { syncFollowSets().catch(() => {}); // throttled inside syncReports().catch(() => {}); // likewise @@ -16455,9 +16544,28 @@ export function messagesFeature(ctx) { }, '↑ ', h('span', { class: 'n', 'data-key': 'n:' + waiting }, String(waiting)), ' ', waiting === 1 ? t('feedOneNewWord') : t('feedNNewWord')) : null; + // The post button floats bottom right wherever the reader is in the + // feed; the composer opens at the top and takes the focus. It and the + // pill are position: fixed — and a page sliding in has a transform on + // it, which makes fixed descendants position within the page instead: + // the button rode at the foot of the feed card, below the screen, until + // the entrance animation ended ("not seeing the Post button right away", + // Adam, 2026-11-02; measured on his phone: 4109 px down at 87 ms, in + // place at 562 ms). So they're handed to the shell, which paints them + // beside the page, outside anything that animates. + const fab = visitor || ui.profCompose != null ? null : h('button', { + class: 'btn-primary feed-post-fab', 'data-key': 'feed-post-fab', type: 'button', title: t('profNewPost'), 'aria-label': t('profNewPost'), + onClick: () => { + ui.profCompose = draftFor(POST_DRAFT) || ''; + render(); + try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch { window.scrollTo(0, 0); } + setTimeout(() => document.querySelector('.post-input')?.focus(), 60); + }, + }, h('span', { class: 'feed-compose-icon', 'aria-hidden': 'true', html: + '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path stroke="currentColor" d="M12 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-6"/><path stroke="currentColor" opacity=".5" d="M8 17h5"/><path class="compose-pen-fill" d="m16.5 3.5 3 3-8 8-4 1 1-4Z"/><path class="compose-pen" d="M16.5 3.5a2.1 2.1 0 0 1 3 3l-8 8-4 1 1-4Z"/><path class="compose-pen" d="m14.5 5.5 3 3"/></svg>' }), h('span', {}, t('feedPostFab'))); + pageFloaters = [pill, fab].filter(Boolean); // the chat shell draws the brand header; this is just the page under it return h('div', { class: 'card col chat-page feed-page', style: 'gap:10px' }, - h('div', { 'data-key': 'feed-notice', style: 'display:contents' }, pill), h('div', { class: 'row gap6', style: 'align-items:center' }, backBtn(() => { stopFeedWatch(); @@ -16480,18 +16588,6 @@ export function messagesFeature(ctx) { ui.feedRelayEdit && ui.feedRelayEdit.id === def.id ? relayPanel(def) : null, visitor ? null : feedChips(), (refreshLive(), mediaModes()), - // floating bottom right, wherever the reader is in the feed: the - // composer opens at the top and takes the focus - visitor || ui.profCompose != null ? null : h('button', { - class: 'btn-primary feed-post-fab', 'data-key': 'feed-post-fab', type: 'button', title: t('profNewPost'), 'aria-label': t('profNewPost'), - onClick: () => { - ui.profCompose = draftFor(POST_DRAFT) || ''; - render(); - try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch { window.scrollTo(0, 0); } - setTimeout(() => document.querySelector('.post-input')?.focus(), 60); - }, - }, h('span', { class: 'feed-compose-icon', 'aria-hidden': 'true', html: - '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path stroke="currentColor" d="M12 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-6"/><path stroke="currentColor" opacity=".5" d="M8 17h5"/><path class="compose-pen-fill" d="m16.5 3.5 3 3-8 8-4 1 1-4Z"/><path class="compose-pen" d="M16.5 3.5a2.1 2.1 0 0 1 3 3l-8 8-4 1 1-4Z"/><path class="compose-pen" d="m14.5 5.5 3 3"/></svg>' }), h('span', {}, t('feedPostFab'))), visitor ? null : postComposer(), def.of && !ofLoaded(def) ? h('div', { class: 'row gap6', style: 'justify-content:center;align-items:center;padding:12px 0' }, @@ -18657,6 +18753,8 @@ export function messagesFeature(ctx) { return unreadCount(); }, bottomNav: appBottomNav, + // taken once per render: a page that isn't the feed leaves none + pageFloaters() { const f = pageFloaters; pageFloaters = []; return f; }, notifySettingsCards() { return [notifyCard()]; }, screenView() { liveShown = false; diff --git a/src/style.css b/src/style.css index b65f632..8a28377 100644 --- a/src/style.css +++ b/src/style.css @@ -1035,17 +1035,11 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } .feed-compose-icon { display: flex; flex-shrink: 0; } .feed-compose-icon .compose-pen { stroke: #14935a; } .feed-compose-icon .compose-pen-fill { fill: #14935a; opacity: .18; } -.feed-page .feed-new-pill { - bottom: calc(env(safe-area-inset-bottom, 0px) + 82px); -} -/* a phone is too narrow for it beside the Post button: it rides above */ -@media (max-width: 480px) { - .feed-page .feed-new-pill { bottom: calc(env(safe-area-inset-bottom, 0px) + 142px); } -} .feed-new-pill { - /* Rides at the bottom of the window, where a thumb already is; the tap - takes the reader up to the new posts. */ - position: fixed; bottom: calc(env(safe-area-inset-bottom, 0px) + 18px); + /* Rides at the bottom of the window, where a thumb already is, above the + nav; the tap takes the reader up to the new posts. (Painted beside the + page, not inside it: see page-floaters in app.js.) */ + position: fixed; bottom: calc(env(safe-area-inset-bottom, 0px) + 82px); left: 50%; transform: translateX(-50%); z-index: 30; width: auto; padding: 12px 24px; border-radius: 999px; background: var(--fill); color: var(--fill-fg); border: none; @@ -1054,6 +1048,10 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } box-shadow: 0 3px 12px rgba(0, 0, 0, .28); animation: pill-in .18s ease-out; } +/* a phone is too narrow for it beside the Post button: it rides above */ +@media (max-width: 480px) { + .feed-new-pill { bottom: calc(env(safe-area-inset-bottom, 0px) + 142px); } +} /* the generic button hover (a light surface) and press (a 1px drop, which REPLACED the centring transform and threw the pill half its width to the right, out from under the pointer, so the tap never landed) stay off it */ @@ -1894,7 +1892,7 @@ body::after { min-height: 48px; padding: 8px 14px; font-size: 16px; } .feed-post-fab { right: calc(50% - var(--col-edge) + 16px); bottom: 24px; } - .feed-new-pill, .feed-page .feed-new-pill { left: calc(50% + var(--col-mid)); bottom: 24px; } + .feed-new-pill { left: calc(50% + var(--col-mid)); bottom: 24px; } .toast { left: calc(50% + var(--col-mid)); } } @media (min-width: 1220px) { diff --git a/tools/blob-alt-test.js b/tools/blob-alt-test.js new file mode 100644 index 0000000..4d958f2 --- /dev/null +++ b/tools/blob-alt-test.js @@ -0,0 +1,92 @@ +// A face or banner whose host has lost the file is found on another server +// that holds the same Blossom blob (same sha256 name): the one the person +// publishes in their kind 10063 first. The small avatar's thumbnail is made +// from the copy, the profile page's big face and banner paint from it, and +// the find is remembered on the device. +// Run: bun tools/blob-alt-test.js +import assert from 'node:assert/strict'; +import puppeteer from './puppeteer.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { nip19 } from 'nostr-tools'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const sk = generateSecretKey(), PK = getPublicKey(sk), now = Math.floor(Date.now() / 1000); +const H1 = 'c11a29785c1af623a45ebfdb23860170a9d0c0ead31c1bcb209085cd662f7a17'; +const H2 = '479711427c577f040a5bf8cb05c47f75abfef37580893dd63b66f777c34dc327'; +const png = (color) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', `color=c=${color}:size=320x320`, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout; +const FACE = png('orange'), COVER = png('teal'); +const html = await buildHtml({ minify: true, pwa: false }); +const hits = { dead: 0, alt: 0 }; +let origin = ''; +const server = Bun.serve({ port: 0, fetch: (req) => { + const u = new URL(req.url); + if (u.pathname.startsWith('/dead/')) { hits.dead++; return new Response('{"error":"The requested blob was not found."}', { status: 404, headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*' } }); } + if (u.pathname === `/alt/${H1}.png`) { hits.alt++; return new Response(FACE, { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*' } }); } + if (u.pathname === `/alt/${H2}.png`) { hits.alt++; return new Response(COVER, { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*' } }); } + if (u.pathname === '/api/feed') return new Response(snapshot, { headers: { 'content-type': 'application/json' } }); + return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } }); +} }); +origin = server.url.origin; +const notes = Array.from({ length: 12 }, (_, i) => finalizeEvent({ kind: 1, created_at: now - 60 - i * 60, tags: [], content: `Post ${i}: the harbour at dusk, boats coming in.` }, sk)); +const profile = finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Karnage', picture: `${origin}/dead/${H1}.png`, banner: `${origin}/dead/${H2}.png`, about: 'Saying the quiet part out loud.' }) }, sk); +const servers = finalizeEvent({ kind: 10063, created_at: now, tags: [['server', `${origin}/alt/`]], content: '' }, sk); +const snapshot = JSON.stringify({ version: 1, generatedAt: Date.now(), notes, profiles: [profile], popUntil: now - 3600 }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +try { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (e) => errors.push(String(e))); + await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); + await page.setRequestInterception(true); + page.on('request', (r) => r.url().startsWith(origin) ? r.continue() : r.abort()); // the public hosts: unreachable here + await page.evaluateOnNewDocument(([notes, profile, servers]) => { + class FakeSocket extends EventTarget { + constructor(url) { super(); this.url = url; this.readyState = 0; this.subs = new Set(); + setTimeout(() => { this.readyState = 1; this.emit('open', new Event('open')); }, 0); } + emit(type, ev) { this.dispatchEvent(ev); this['on' + type]?.(ev); } + message(d) { this.emit('message', new MessageEvent('message', { data: JSON.stringify(d) })); } + send(raw) { + let m; try { m = JSON.parse(raw); } catch { return; } + if (m[0] === 'CLOSE') { this.subs.delete(m[1]); return; } + if (m[0] !== 'REQ') return; + const [, id, f] = m; this.subs.add(id); + let evs = []; + if (f.kinds?.includes(1) && !f.since) evs = notes.filter((e) => (!f.until || e.created_at <= f.until) + && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id))).slice(0, f.limit || 100); + if (f.kinds?.includes(0) && f.authors?.includes(profile.pubkey)) evs = [profile]; + if (f.kinds?.includes(10063) && f.authors?.includes(servers.pubkey)) evs = [servers]; + if (f.ids) evs = notes.filter((e) => f.ids.includes(e.id)); + setTimeout(() => { if (!this.subs.has(id)) return; for (const e of evs) this.message(['EVENT', id, e]); this.message(['EOSE', id]); }, 30); + } + close() { this.readyState = 3; this.emit('close', new Event('close')); } + } + Object.assign(FakeSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); + window.WebSocket = FakeSocket; + }, [notes, profile, servers]); + const bg = (sel) => page.evaluate((s) => document.querySelector(s)?.style.backgroundImage || '', sel); + // the feed: the small face's thumbnail is made from the copy + await page.goto(origin + '/feed', { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => document.querySelectorAll('.notes-feed > [data-zap-post]').length >= 5, { timeout: 20000 }); + // the copy is found and the thumbnail made from it (the probe and the + // thumbnail's own fetch: two hits); the row keeps the look it was admitted + // with, the next paint wears the face + for (let i = 0; i < 80 && hits.alt < 2; i++) await sleep(250); + assert.ok(hits.dead >= 1 && hits.alt >= 2, `the dead host was asked (${hits.dead}) and the copy fetched for the thumbnail (${hits.alt})`); + console.log('✓ the thumbnail is made from the copy on the published server'); + const remembered = await page.evaluate(() => localStorage.getItem('btc-wallet-blob-alts') || ''); + assert.ok(remembered.includes(`/alt/${H1}.png`), 'the find is remembered on the device'); + // the profile page (the screen that showed an empty circle and no cover): + // big face and banner from the copies, the post rows' small faces from + // the thumbnail + await page.goto(origin + '/' + nip19.npubEncode(PK), { waitUntil: 'domcontentloaded' }); + await page.waitForFunction((h) => (document.querySelector('.profile-banner')?.style.backgroundImage || '').includes('/alt/' + h), { timeout: 20000 }, H2) + .catch(async () => { throw new Error('banner not from the copy: ' + await bg('.profile-banner')); }); + const face = await bg('.profile-avatar'); + assert.ok(face.includes(`/alt/${H1}.png`), 'the big face paints from the copy: ' + face); + console.log('✓ the profile page paints the big face and the banner from the copies'); + const rowSel = `.note-post[data-zap-post="${notes[0].id}"] .ava-img`; + await page.waitForFunction((s, h) => /^url\("data:/.test(document.querySelector(s)?.style.backgroundImage || '') || (document.querySelector(s)?.style.backgroundImage || '').includes('/alt/' + h), { timeout: 20000 }, rowSel, H1) + .catch(async () => { throw new Error('post row face: ' + await page.evaluate((id) => [...document.querySelector('.note-post[data-zap-post="' + id + '"]')?.querySelectorAll('[class*=avatar], .ava-img') || []].map((e) => e.outerHTML.slice(0, 220)).join(' || '), notes[0].id)); }); + assert.deepEqual(errors, []); + console.log('✓ the post rows wear the face, and no page errors'); +} finally { await browser.close(); server.stop(true); } diff --git a/tools/feed-bottom-nav-test.js b/tools/feed-bottom-nav-test.js index ea466fd..ccba776 100644 --- a/tools/feed-bottom-nav-test.js +++ b/tools/feed-bottom-nav-test.js @@ -13,7 +13,7 @@ const wallet = { xpub: 'test-wallet', loaded: true, nostrRelays: () => [], loadFeatureState: (k, d) => k === 'follows' ? { tags: [['p', author]], at: Date.now()/1000 } : d, saveFeatureState() {}, registerCacheExtension() {} }; let feature; -function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView() || h('div', { class: 'wallet-screen' }, 'Wallet'), feature.bottomNav()]); } +function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView() || h('div', { class: 'wallet-screen' }, 'Wallet'), ...(feature.pageFloaters ? feature.pageFloaters() : []), feature.bottomNav()]); } feature = messagesFeature({ h, ui, wallet, render, toast() {}, hook: name => name === 'unreadMessages' ? 1 : null, brandHeader: () => h('div', { style: 'height:60px' }, 'Coinos') }); window.test = { ui, wallet, render, get feature() { return feature; }, async feed() { ui.chatOpen = true; ui.msgView = 'feed'; ui.noteThread = null; diff --git a/tools/feed-fab-anim-test.js b/tools/feed-fab-anim-test.js new file mode 100644 index 0000000..bd9379c --- /dev/null +++ b/tools/feed-fab-anim-test.js @@ -0,0 +1,58 @@ +// The post button is on screen the moment the feed is, while the page is +// still sliding in. It is position: fixed, and it used to be rendered inside +// the page — whose entrance animation puts a transform on it, which makes +// fixed descendants position within the page: the button rode at the foot +// of the feed card, below the screen, until the animation ended (measured +// on a phone: 4109 px down at 87 ms, in place at 562 ms). Now the shell +// paints it beside the page. +// Run: bun tools/feed-fab-anim-test.js +import assert from 'node:assert/strict'; +import puppeteer from './puppeteer.js'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: (req) => { + const p = new URL(req.url).pathname; + if (p === '/verify-worker.js' || p.startsWith('/punks')) return new Response(Bun.file('dist' + p)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const account = { id: 'fab-test', label: 'Fab', type: 'full', mnemonic: generateMnemonic(wordlist), passphrase: '', network: 'regtest', persisted: true }; +try { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); + await page.evaluateOnNewDocument((account) => { + localStorage.setItem('btc-wallet-network', 'regtest'); + sessionStorage.setItem('btc-wallet-accounts', JSON.stringify({ accounts: [account], activeId: account.id })); + }, account); + await page.goto(server.url.origin + '/', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.app-bottom-nav', { timeout: 20000 }); + await sleep(1500); + const tap = (which) => page.evaluate((which) => new Promise((resolve) => { + const b = [...document.querySelectorAll('.app-bottom-nav button')].find((x) => new RegExp(which, 'i').test(x.textContent)); + const t0 = performance.now(); const samples = []; + const tick = () => { + const f = document.querySelector('.feed-post-fab'); const ms = Math.round(performance.now() - t0); + if (f) { const r = f.getBoundingClientRect(); samples.push({ ms, top: Math.round(r.top), right: Math.round(innerWidth - r.right), anim: !!document.querySelector('.anim-page'), inPage: !!f.closest('.anim-page, .feed-page') }); } + if (ms < 320) requestAnimationFrame(tick); else resolve(samples); + }; + b.click(); requestAnimationFrame(tick); + }), which); + for (const round of [1, 2]) { + await tap('wallet'); await sleep(900); + const s = await tap('feed'); + assert.ok(s.length >= 5, 'the button was sampled while the page slid in: ' + s.length); + const first = s[0]; + assert.ok(first.anim, 'the page was still animating at the first sample (' + first.ms + ' ms)'); + assert.ok(!first.inPage, 'the button is painted beside the page, not inside it'); + const off = s.filter((x) => x.top > 844 - 40 || x.top < 0); + assert.equal(off.length, 0, `the button sat off screen in ${off.length} of ${s.length} frames: ` + JSON.stringify(s.slice(0, 4))); + console.log(`✓ round ${round}: the post button is on screen from the first frame (top ${first.top}px at ${first.ms} ms, ${s.length} frames sampled)`); + } + assert.deepEqual(errors, []); + console.log('✓ no page errors'); +} finally { await browser.close(); server.stop(true); } diff --git a/tools/feed-new-posts-test.js b/tools/feed-new-posts-test.js index 59d6f7c..d66ab59 100644 --- a/tools/feed-new-posts-test.js +++ b/tools/feed-new-posts-test.js @@ -14,7 +14,7 @@ const wallet = { xpub: 'test-wallet', loaded: true, nostrRelays: () => [], loadFeatureState: (k, d) => k === 'follows' ? { tags: [['p', author]], at: Date.now()/1000 } : d, saveFeatureState() {}, registerCacheExtension() {} }; let feature; -function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView() || h('div', { class: 'wallet-screen' }, 'Wallet'), feature.bottomNav()]); } +function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView() || h('div', { class: 'wallet-screen' }, 'Wallet'), ...(feature.pageFloaters ? feature.pageFloaters() : []), feature.bottomNav()]); } feature = messagesFeature({ h, ui, wallet, render, toast() {}, hook: name => name === 'unreadMessages' ? 1 : null, brandHeader: () => h('div', { style: 'height:60px' }, 'Coinos') }); window.test = { ui, wallet, render, get feature() { return feature; }, async feed() { ui.chatOpen = true; ui.msgView = 'feed'; ui.noteThread = null; diff --git a/tools/feed-streaming-test.js b/tools/feed-streaming-test.js index c69b1eb..4fa00cf 100644 --- a/tools/feed-streaming-test.js +++ b/tools/feed-streaming-test.js @@ -26,7 +26,7 @@ const wallet = { xpub: 'test-wallet', loaded: true, nostrRelays: () => ['wss://k : k === 'feedNotes' ? seeded : k === 'profiles' ? { [author]: { name: 'Stream Author', t: Date.now() } } : d, saveFeatureState() {}, registerCacheExtension() {} }; let feature; -function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView(), feature.bottomNav()]); } +function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView(), ...(feature.pageFloaters ? feature.pageFloaters() : []), feature.bottomNav()]); } feature = messagesFeature({ h, ui, wallet, render, toast() {}, hook: () => null, brandHeader: () => h('div', { style: 'height:60px' }, 'Coinos') }); window.test = { feature, render }; |
