From b22b0c98c673df984f347db2be580c2551e113f7 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 28 Sep 2026 21:41:22 -0700 Subject: The chat list shows four conversations and four communities, the rest behind Show all. Co-Authored-By: Claude Opus 5.5 --- src/i18n.js | 1 + 1 file changed, 1 insertion(+) (limited to 'src/i18n.js') diff --git a/src/i18n.js b/src/i18n.js index eff5a73..ce5bf7f 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -425,6 +425,7 @@ const STR = { msgNoDms: 'No conversations yet.', msgShowAllDms: 'Show all {n} conversations', msgShowFewerDms: 'Show fewer', + msgShowAllCommunities: 'Show all {n} communities', msgNoDmsYet: 'No messages yet. Say hi!', msgDmPlaceholder: 'Message', msgDmEncrypted: 'Private message · end-to-end encrypted', -- cgit v1.2.3 From c0e428aff3ff34fec56a434bc1837e49ab9c1a81 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 28 Sep 2026 22:58:40 -0700 Subject: Polls (NIP-88): options with each one's share of the vote, votable from the feed and the thread. A kind 1068 poll showed as "an event this app doesn't show". Now its options are rows with the vote share behind them (kind 1018 votes, each person's latest counts, single/multiple choice, endsAt honored); a tap votes, marked as yours at once. A quoted poll is pictured in its card. Feeds and profiles list polls alongside notes, and a thread's root is looked up by id with any kind, so replies to a poll find it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp --- src/features/messages.js | 132 ++++++++++++++++++++++++++++++++++++++++----- src/feed-cache.js | 2 +- src/i18n.js | 5 ++ src/style.css | 18 +++++++ tools/poll-test.js | 93 ++++++++++++++++++++++++++++++++ tools/thread-cache-test.js | 3 +- 6 files changed, 238 insertions(+), 15 deletions(-) create mode 100644 tools/poll-test.js (limited to 'src/i18n.js') diff --git a/src/features/messages.js b/src/features/messages.js index 2a3b400..c6a99b5 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -446,6 +446,8 @@ export function messagesFeature(ctx) { if (wallet.nostr && wallet.nostr.sk) return { pubkey: wallet.nostr.pk, signer: wallet.nostr.sk }; return null; } + // what a feed or a profile lists: notes, and polls (NIP-88) + const FEED_KINDS = [1, 1068]; const myPubkeys = () => { const pks = []; const id = hook('nostrLoginIdentity'); @@ -3461,7 +3463,7 @@ export function messagesFeature(ctx) { : { status: 'loading', notes: seed }; notesCache.set(pk, c); (async () => { - const evs = await queryOn(await notesRelays(pk), { kinds: [1], authors: [pk], limit: 30 }, 4500); + const evs = await queryOn(await notesRelays(pk), { kinds: FEED_KINDS, authors: [pk], limit: 30 }, 4500); const seen = new Set(); const fresh = (evs || []) .filter((e) => !seen.has(e.id) && seen.add(e.id)) @@ -3507,7 +3509,7 @@ export function messagesFeature(ctx) { render(); try { const evs = await queryOn(await notesRelays(pk), - { kinds: [1], authors: [pk], limit: 30, until: oldest.created_at - 1 }, 4500); + { kinds: FEED_KINDS, authors: [pk], limit: 30, until: oldest.created_at - 1 }, 4500); const seen = new Set(c.notes.map((e) => e.id)); const older = (evs || []) .filter((e) => !seen.has(e.id) && seen.add(e.id)) @@ -4290,10 +4292,10 @@ export function messagesFeature(ctx) { return; } try { - const events = await queryOn(feedRelaysOr(def, def && def.all ? FIREHOSE_RELAYS : NOTE_RELAYS), { kinds: [1], limit: FEED_PAGE, + const events = await queryOn(feedRelaysOr(def, def && def.all ? FIREHOSE_RELAYS : NOTE_RELAYS), { kinds: FEED_KINDS, limit: FEED_PAGE, ...(authors.length ? { authors } : {}), ...(topics.length ? { '#t': topics } : {}) }, 3000); if (c.stopped) return; - await mergeFeed(events.filter((e) => e.kind === 1 && !isReply(e) && !hidden(e)) + await mergeFeed(events.filter((e) => FEED_KINDS.includes(e.kind) && !isReply(e) && !hidden(e)) .sort((a, b) => b.created_at - a.created_at).slice(0, FEED_PAGE), {}, c); } catch {} finally { if (!c.stopped) { @@ -4744,7 +4746,7 @@ export function messagesFeature(ctx) { if (!staged) feedStaged.set(c, staged = new Set()); const known = new Set([...c.notes, ...(c.catchup || []), ...(c.deferred || [])].map((e) => e.id)); for (const e of evs || []) noteForSpam(e); - let add = (evs || []).filter((e) => e.kind === 1 && !isReply(e) && !hidden(e) + let add = (evs || []).filter((e) => FEED_KINDS.includes(e.kind) && !isReply(e) && !hidden(e) && !known.has(e.id) && !staged.has(e.id) && known.add(e.id) && staged.add(e.id)); if (!add.length) return false; const def = feedDef(c.id); @@ -4872,7 +4874,7 @@ export function messagesFeature(ctx) { // the firehose asks its relays for everything if (!topics.length && !def.all) return false; const relays = feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS); - const evs = await queryOn(relays, { kinds: [1], ...tag, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []); + const evs = await queryOn(relays, { kinds: FEED_KINDS, ...tag, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []); if (await mergeFeed(evs, merge, c)) { got = true; scheduleRepaint(); } return got; } @@ -4883,7 +4885,7 @@ export function messagesFeature(ctx) { const chunks = []; for (let i = 0; i < a.length; i += REQ_AUTHORS) chunks.push(a.slice(i, i + REQ_AUTHORS)); return chunks.map(async (chunk) => { - const evs = await queryOn(relays, { kinds: [1], authors: chunk, ...tag, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []); + const evs = await queryOn(relays, { kinds: FEED_KINDS, authors: chunk, ...tag, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []); if (await mergeFeed(evs, merge, c)) { got = true; scheduleRepaint(); } }); })); @@ -5054,11 +5056,11 @@ export function messagesFeature(ctx) { const on = (ev) => { mergeFeed([ev], { live: true }, c).then((ok) => { if (ok) scheduleRepaint(); }).catch(() => {}); }; const authors = feedAuthors(def); if (def.curated) { const iv = setInterval(() => { if (c === feed && ui.chatOpen && ui.msgView === 'feed') refreshFeed({ force: true, live: true }, c).catch(() => {}); }, 120_000); feedUnsubs.push(() => clearInterval(iv)); return; } - if (!authors.length) { feedUnsubs.push(subscribeOn(feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds: [1], ...tag, since }, on)); return; } + if (!authors.length) { feedUnsubs.push(subscribeOn(feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds: FEED_KINDS, ...tag, since }, on)); return; } const plan = feedRelays(def) ? [{ relays: feedRelays(def), authors }] : outboxPlan(authors); for (const { relays, authors: a } of plan) for (let i = 0; i < a.length; i += REQ_AUTHORS) - feedUnsubs.push(subscribeOn(relays, { kinds: [1], authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on)); + feedUnsubs.push(subscribeOn(relays, { kinds: FEED_KINDS, authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on)); } function stopFeedWatch() { for (const u of feedUnsubs) { try { u(); } catch {} } @@ -5379,7 +5381,8 @@ export function messagesFeature(ctx) { h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, // one level deep only: a quote of a quote of a quote is a rabbit // hole, and the inner one stays a link you can follow - ...noteBody(ev.content, depth + 1, emojiTagMap(ev.tags)))); + ...noteBody(ev.content, depth + 1, emojiTagMap(ev.tags))), + ev.kind === POLL ? pollBox(ev, { interactive: false }) : null); } // One URL, rendered as whatever it points at. `isImage` is markdown saying @@ -5853,6 +5856,108 @@ export function messagesFeature(ctx) { } // React with whichever emoji was picked (NIP-25 takes any content). + // ---- polls (NIP-88) ------------------------------------------------------- + // A kind 1068 poll is its question (content) plus ["option", id, label] + // tags; votes are kind 1018 events e-tagging it with ["response", id]. + // Each person's LATEST vote counts (one option unless the poll says + // "multiplechoice"), and nothing cast after its endsAt. + const POLL = 1068, POLL_VOTE = 1018; + const pollOptions = (ev) => ev.tags.filter((x) => x[0] === 'option' && x[1] != null && x[2]) + .map((x) => ({ id: String(x[1]), label: String(x[2]) })); + const pollMulti = (ev) => (ev.tags.find((x) => x[0] === 'polltype') || [])[1] === 'multiplechoice'; + const pollEnds = (ev) => { const v = Number((ev.tags.find((x) => x[0] === 'endsAt') || [])[1]); return Number.isFinite(v) && v > 0 ? v : 0; }; + const pollEnded = (ev) => !!pollEnds(ev) && Date.now() / 1000 > pollEnds(ev); + const pollRelays = async (ev) => [...new Set([ + ...ev.tags.filter((x) => x[0] === 'relay' && /^wss?:\/\//i.test(x[1] || '')).map((x) => x[1]), + ...(await noteRelaysFor(ev).catch(() => zapRelays())), + ])].slice(0, 12); + const pollVotes = new Map(); // poll id -> { at, loaded, loading, by: Map(pk -> { t, ids }) } + function pollState(ev) { + let st = pollVotes.get(ev.id); + if (!st) { st = { at: 0, loaded: false, loading: false, by: new Map() }; pollVotes.set(ev.id, st); } + return st; + } + function pollCount(poll, v) { + const st = pollState(poll); + const ends = pollEnds(poll); + if (ends && v.created_at > ends) return; + const prev = st.by.get(v.pubkey); + if (prev && prev.t >= v.created_at) return; + const valid = new Set(pollOptions(poll).map((o) => o.id)); + let ids = [...new Set(v.tags.filter((x) => x[0] === 'response').map((x) => String(x[1])))].filter((id) => valid.has(id)); + if (!pollMulti(poll)) ids = ids.slice(0, 1); + st.by.set(v.pubkey, { t: v.created_at, ids }); + } + // Votes are asked for when a poll is painted, and again a minute on. + function pollTally(ev) { + const st = pollState(ev); + if (!st.loading && Date.now() - st.at > 60_000) { + st.loading = true; st.at = Date.now(); + (async () => { + const evs = await queryOn(await pollRelays(ev), { kinds: [POLL_VOTE], '#e': [ev.id], limit: 1000 }, 5000).catch(() => []); + for (const v of evs || []) if (v.kind === POLL_VOTE) pollCount(ev, v); + })().finally(() => { st.loading = false; st.loaded = true; scheduleRepaint(); }); + } + return st; + } + async function votePoll(poll, optId) { + if (pollEnded(poll)) return; + const id = await requireIdentity(); + const st = pollState(poll); + const prev = st.by.get(id.pubkey); + const mine = prev?.ids || []; + const ids = pollMulti(poll) ? (mine.includes(optId) ? mine.filter((x) => x !== optId) : [...mine, optId]) : [optId]; + if (!pollMulti(poll) && mine[0] === optId) return; + const partial = { + kind: POLL_VOTE, content: '', + // strictly newer than our last vote, or relays and counters keep the old one + created_at: Math.max(Math.floor(Date.now() / 1000), (prev?.t || 0) + 1), + tags: [['e', poll.id], ['p', poll.pubkey], ...ids.map((x) => ['response', x]), CLIENT_TAG], + }; + const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + pollCount(poll, evt); // counted on screen before the relays answer + render(); + const ok = await publishOn(await pollRelays(poll), evt).catch(() => false); + if (!ok) toast(t('msgSendFailed')); + } + // The options as rows with their share of the vote. In a quote card + // (`interactive` off) they are a picture of the poll — the tap opens it. + function pollBox(ev, { interactive = true } = {}) { + const opts = pollOptions(ev); + if (!opts.length) return null; + const st = pollTally(ev); + const counts = new Map(opts.map((o) => [o.id, 0])); + let voters = 0; + for (const v of st.by.values()) { + if (!v.ids.length) continue; + voters++; + for (const x of v.ids) counts.set(x, (counts.get(x) || 0) + 1); + } + const mine = new Set(myPubkeys().flatMap((pk) => st.by.get(pk)?.ids || [])); + const ended = pollEnded(ev); + const canVote = interactive && !ended; + const shown = st.loaded || voters > 0; + const foot = [ + shown ? t(voters === 1 ? 'pollVoter' : 'pollVoters', { n: voters }) : null, + pollMulti(ev) ? t('pollMulti') : null, + ended ? t('pollEnded') : pollEnds(ev) ? t('pollEndsAt', { when: new Date(pollEnds(ev) * 1000).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) }) : null, + ].filter(Boolean).join(' \u00b7 '); + return h('div', { class: 'poll' }, + ...opts.map((o) => { + const n = counts.get(o.id) || 0; + const pct = voters ? Math.round((n / voters) * 100) : 0; + return h(canVote ? 'button' : 'div', { + class: 'poll-opt' + (mine.has(o.id) ? ' mine' : '') + (canVote ? '' : ' static'), + type: canVote ? 'button' : undefined, + onClick: canVote ? (e) => { e.stopPropagation(); votePoll(ev, o.id).catch(() => {}); } : undefined, + }, + h('span', { class: 'poll-bar', style: 'width:' + (shown ? pct : 0) + '%' }), + h('span', { class: 'poll-label' }, (mine.has(o.id) ? '\u2713 ' : '') + o.label), + shown ? h('span', { class: 'poll-pct' }, pct + '%') : null); + }), + foot ? h('div', { class: 'small faint poll-foot' }, foot) : null); + } + async function reactTo(ev, emoji) { const id = await requireIdentity(); const relays = await noteRelaysFor(ev); @@ -6495,7 +6600,7 @@ export function messagesFeature(ctx) { // An event of a kind this app has no renderer for (a chess game, kind // 64, reached through a reaction to it) is shown as what its author's // client said it is (NIP-31 alt), never as raw content dressed as a post. - const foreign = ev.kind !== 1 && ev.kind !== 30023; + const foreign = ev.kind !== 1 && ev.kind !== 30023 && ev.kind !== POLL; const altText = foreign ? ((ev.tags.find((x) => x[0] === 'alt') || [])[1] || t('noteForeignKind', { kind: ev.kind })) : null; const isReply = !foreign && ev.tags.some((x) => x[0] === 'e'); const canZap = canZapPk(pk); @@ -6545,6 +6650,7 @@ export function messagesFeature(ctx) { foreign ? h('div', { class: 'note-text small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, (ev.kind === 64 ? '\u265f ' : '') + altText) : h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...noteBody(ev.content, 0, emojiTagMap(ev.tags))), + ev.kind === POLL ? pollBox(ev) : null, pending ? null : noteActions(pk, ev, { canZap }), !pending && whoOpen(ev.id) ? whoPanel(ev) : null)); } @@ -6667,7 +6773,7 @@ export function messagesFeature(ctx) { }; let relays = await relaysFor(c.root || seed); const [roots, replies] = await Promise.all([ - c.root ? Promise.resolve([]) : queryOn(relays, { kinds: [1], ids: [rootId] }, 4000), + c.root ? Promise.resolve([]) : queryOn(relays, { ids: [rootId] }, 4000), // a root can be a poll queryOn(relays, { kinds: [1], '#e': [rootId], limit: 80 }, 4500), ]); if (!c.root) c.root = (roots || [])[0] || null; @@ -6681,7 +6787,7 @@ export function messagesFeature(ctx) { if (up === top.id) break; relays = [...new Set([...relays, ...(await relaysFor(top))])].slice(0, 14); const [ups, more] = await Promise.all([ - queryOn(relays, { kinds: [1], ids: [up] }, 4000), + queryOn(relays, { ids: [up] }, 4000), queryOn(relays, { kinds: [1], '#e': [up], limit: 80 }, 4500), ]); const upNote = (ups || [])[0]; diff --git a/src/feed-cache.js b/src/feed-cache.js index ea562f7..7394393 100644 --- a/src/feed-cache.js +++ b/src/feed-cache.js @@ -3,7 +3,7 @@ export const FEED_CACHE_POSTS = 30; const MAX_FEEDS = 4, MAX_UNITS = 256_000, MAX_AGE = 7 * 86400_000; export function createFeedCache(storage, prefix, now = Date.now) { const keyOf = (id) => id === 'following' ? prefix : prefix + ':' + id; - const valid = (e) => e?.kind === 1 && typeof e.id === 'string' && typeof e.pubkey === 'string' + const valid = (e) => (e?.kind === 1 || e?.kind === 1068) && typeof e.id === 'string' && typeof e.pubkey === 'string' && typeof e.content === 'string' && Number.isFinite(e.created_at) && Array.isArray(e.tags); function compact(notes) { const kept = []; let size = 0; diff --git a/src/i18n.js b/src/i18n.js index ce5bf7f..02c60aa 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -460,6 +460,11 @@ const STR = { arkFeeService: 'Service fee', arkCoinsExpired: 'expired', noteForeignKind: 'A kind {kind} event this app doesn’t show.', + pollVoter: '{n} vote', + pollVoters: '{n} votes', + pollMulti: 'Pick any', + pollEnded: 'Poll closed', + pollEndsAt: 'Closes {when}', arkExpiredDust: '{amount} has expired and is too small to renew. You can forget it from the Manage page.', arkCoinForget: 'Forget', arkCoinForgetTitle: 'Give this coin up: it has expired and is too small to renew or exit.', diff --git a/src/style.css b/src/style.css index 15fcad0..9b0bf45 100644 --- a/src/style.css +++ b/src/style.css @@ -768,6 +768,24 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } .quote-card.clickable { cursor: pointer; } .quote-card.clickable:hover { border-color: var(--line-strong); } .quote-loading { flex-direction: row; align-items: center; gap: 8px; } +/* A poll (NIP-88): each option a row with its share of the vote as a bar + behind the label. Only the bar's width changes as votes arrive, so the + post never changes height under the reader. */ +.poll { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; } +.poll-opt { + position: relative; overflow: hidden; display: flex; align-items: center; gap: 8px; + width: 100%; min-height: 40px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 10px; + background: var(--surface2); color: inherit; font: inherit; text-align: left; cursor: pointer; +} +.poll-opt.static { cursor: inherit; min-height: 34px; padding: 6px 10px; } +.poll-opt:not(.static):hover { border-color: var(--line-strong); } +.poll-opt.mine { border-color: var(--accent); } +.poll-bar { position: absolute; inset: 0 auto 0 0; background: var(--line); transition: width .3s ease; pointer-events: none; } +.poll-opt.mine .poll-bar { background: var(--line-strong); } +.poll-label { position: relative; flex: 1; min-width: 0; overflow-wrap: anywhere; } +.poll-opt.mine .poll-label { font-weight: 600; } +.poll-pct { position: relative; font-variant-numeric: tabular-nums; color: var(--muted); font-size: 14px; } +.poll-foot { margin-top: 2px; } /* Posts that landed while you were reading further down. Floats over the feed near the top, out of the way of the thumb, and takes you up to them when tapped. Fixed, not sticky: the feed is the page scroller, and a diff --git a/tools/poll-test.js b/tools/poll-test.js new file mode 100644 index 0000000..e9cb0d6 --- /dev/null +++ b/tools/poll-test.js @@ -0,0 +1,93 @@ +// Polls (NIP-88, kind 1068): shown as their options with each one's share of +// the vote (kind 1018), votable from the feed and the thread, and pictured +// in a quote card. Uses a real poll from the relays; outgoing votes are +// caught at the socket so the test never votes on someone's real poll. +// +// Run: bun tools/poll-test.js +import puppeteer from 'puppeteer-core'; +import { SimplePool } from 'nostr-tools/pool'; +import { cacheKeyFor } from '../src/wallet.js'; +import { neventOf } from '../src/nostr.js'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; + +const POLL_ID = '000002f7c6a38223a9843d6e93ce8c15a3048bc95003690d74341cf9de372bfc'; +const pool = new SimplePool(); +const [poll] = await pool.querySync(['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net'], { ids: [POLL_ID] }, { maxWait: 6000 }); +if (!poll) { console.log(' ✗ the poll is not on the relays'); process.exit(1); } +const labels = poll.tags.filter((x) => x[0] === 'option').map((x) => x[2]); + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5273, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.evaluateOnNewDocument(() => { + window.__votes = []; + const send = WebSocket.prototype.send; + WebSocket.prototype.send = function (d) { + if (typeof d === 'string' && d.startsWith('["EVENT"') && /"kind":1018\b/.test(d)) { window.__votes.push(JSON.parse(d)[1]); return; } + return send.call(this, d); + }; +}); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, ms = 15000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5273/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + const Q = 'a'.repeat(63) + '9'; + await page.evaluate(([k, poll, q, ref]) => { + const now = Math.floor(Date.now() / 1000); + const quoting = { id: 'd'.repeat(64), pubkey: q, kind: 1, created_at: now, content: 'vote on this nostr:' + ref, tags: [] }; + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', poll.pubkey], ['p', q]], c: '', at: now })); + localStorage.setItem(k + ':feedNotes', JSON.stringify([quoting, poll])); + localStorage.setItem(k + ':profiles', JSON.stringify({ [poll.pubkey]: { name: 'Poll Author', t: Date.now() }, [q]: { name: 'Quoter', t: Date.now() } })); + }, [base, poll, Q, neventOf(poll.id, poll.pubkey)]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click()); + await waitFor(() => document.querySelectorAll('.notes-feed .poll').length >= 2, 20000); + + const feedPoll = await page.evaluate(() => { + const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((r) => r.querySelector(':scope .poll button.poll-opt')); + return row && { opts: [...row.querySelectorAll('.poll-opt .poll-label')].map((n) => n.textContent), text: row.innerText }; + }); + check('a poll in the feed shows its options', !!feedPoll && JSON.stringify(feedPoll.opts) === JSON.stringify(labels), JSON.stringify(feedPoll?.opts)); + check('...and not the "doesn’t show" placeholder', !(await page.evaluate(() => document.body.innerText.includes('doesn’t show')))); + check('its votes come in from the relays', await waitFor(() => /\d+ votes?/.test(document.querySelector('.notes-feed .poll-foot')?.textContent || ''), 15000), + await page.evaluate(() => document.querySelector('.notes-feed .poll-foot')?.textContent)); + const quoted = await page.evaluate(() => { const q = document.querySelector('.quote-card .poll'); return q && { n: q.querySelectorAll('.poll-opt.static').length, buttons: q.querySelectorAll('button').length }; }); + check('a quoted poll is pictured in its card, not votable there', !!quoted && quoted.n === labels.length && quoted.buttons === 0, JSON.stringify(quoted)); + + // vote for the last option from the feed + const before = await page.evaluate(() => location.href); + await page.evaluate(() => { const b = [...document.querySelectorAll('.notes-feed button.poll-opt')].pop(); b.click(); }); + await waitFor(() => window.__votes.length > 0, 10000); + const vote = await page.evaluate(() => window.__votes[0]); + const lastId = poll.tags.filter((x) => x[0] === 'option').pop()[1]; + check('tapping an option publishes a kind 1018 vote for it', vote && vote.kind === 1018 + && vote.tags.some((x) => x[0] === 'e' && x[1] === poll.id) && vote.tags.filter((x) => x[0] === 'response').map((x) => x[1]).join() === lastId, + JSON.stringify(vote?.tags)); + check('...marked as mine at once', await waitFor(() => !!document.querySelector('.notes-feed button.poll-opt.mine'), 3000)); + check('...without opening the thread', (await page.evaluate(() => location.href)) === before); + + // the thread: the poll as its root + await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.querySelector(':scope .poll button.poll-opt')); r.querySelector('.note-text').click(); }); + check('the poll opens as a thread', await waitFor(() => !!document.querySelector('.thread-page .poll button.poll-opt'), 10000)); + check('...still showing my vote', await page.evaluate(() => !!document.querySelector('.thread-page .poll-opt.mine'))); + if (process.env.SHOT) { await page.evaluate(() => window.scrollTo(0, 0)); await sleep(500); await page.screenshot({ path: process.env.SHOT }); } + check('...with its replies', await waitFor(() => document.querySelectorAll('.thread-page [data-zap-post]').length > 1, 15000)); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ polls read and vote' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/thread-cache-test.js b/tools/thread-cache-test.js index e8b0ccf..6bac353 100644 --- a/tools/thread-cache-test.js +++ b/tools/thread-cache-test.js @@ -84,7 +84,8 @@ try { const { messagesFeature } = await import(path); let requests = [], waiting = []; globalThis.__threadQuery = (filter) => { - if (filter.kinds?.includes(1) && (filter.ids || filter['#e'])) { + // root lookups by id carry no kind (a root can be a poll); reply queries are kind 1 + if (filter.ids || (filter.kinds?.includes(1) && filter['#e'])) { requests.push(filter); return new Promise((resolve) => waiting.push({ filter, resolve })); } -- cgit v1.2.3 From d5598a131a9d160b929ff7ccbd650a4ef57b21c0 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 28 Sep 2026 23:29:55 -0700 Subject: "Save feed" on someone's timeline adds it to your feeds at once, instead of an editor listing every person they follow. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor copied their whole follow list into the form, one row each — long enough to freeze the browser. A saved timeline now keeps only the person (of: pk); their follow list is read from the relays when it is opened, so it stays small in sync and follows who they follow. Its ⋯ offers Delete rather than Edit, and list pickers leave it out. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp --- src/features/messages.js | 72 +++++++++++++++++++++++++++++++++----------- src/i18n.js | 1 + tools/avatar-profile-test.js | 14 +++++++++ 3 files changed, 70 insertions(+), 17 deletions(-) (limited to 'src/i18n.js') diff --git a/src/features/messages.js b/src/features/messages.js index 452377e..2cf799b 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -4173,7 +4173,7 @@ export function messagesFeature(ctx) { if (!ui.listPick) return null; const pk = ui.listPick; const close = () => { ui.listPick = null; render(); }; - const feeds = st().feeds; + const feeds = st().feeds.filter((f) => !f.of); // a timeline is someone, not a list const toggle = (f) => { const s = st(); const def = s.feeds.find((x) => x.id === f.id); @@ -4245,8 +4245,37 @@ export function messagesFeature(ctx) { for (const pk of def.authors || []) if (pk) set.add(pk); for (const pk of def.priv || []) if (pk) set.add(pk); // a list's private members for (const pk of packAuthors(def)) set.add(pk); + if (def.of) for (const pk of ofFollows.get(def.of)?.authors || []) set.add(pk); return [...set].slice(0, FEED_AUTHORS_MAX); }; + // Someone's timeline is their follow list, read from the relays when the + // feed is opened (and again after ten minutes) — never copied into the + // feed itself, so a saved one stays small in sync and follows them. + const ofFollows = new Map(); // pk -> { authors, at, loading } + const ofLoaded = (def) => !!ofFollows.get(def.of)?.at; + function loadOfFollows(def) { + const pk = def.of; + let f = ofFollows.get(pk); + if (!f) { f = { authors: [], at: 0, loading: false }; ofFollows.set(pk, f); } + if (f.loading || (f.at && Date.now() - f.at < 600_000)) return; + f.loading = true; + liveProfileOf(pk); // their name and face for the title + queryOn([...new Set([...(def.relays || []), ...PROFILE_RELAYS, ...NOTE_RELAYS])], { kinds: [3], authors: [pk] }, 6000) + .catch(() => []) + .then((evs) => { + const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0]; + const had = f.at; + if (newest || !had) f.authors = newest ? hexList(newest.tags.filter((x) => x[0] === 'p').map((x) => x[1])) : []; + f.at = Date.now(); f.loading = false; + // whatever was built before the list was known + for (const id of [...feedStates.keys()]) { + if (feedDef(id)?.of !== pk || id === curFeedId) continue; + dropFeedState(id); + } + if (feedDef(curFeedId)?.of === pk) { dropFeedState(curFeedId); selectFeed(curFeedId); } + scheduleRepaint(); + }); + } function feedNow() { if (!feedDef(curFeedId)) curFeedId = FOLLOWING; // a feed deleted on another device @@ -4308,6 +4337,7 @@ export function messagesFeature(ctx) { // history restores ui.feedId and the view catches up from here. function selectFeed(id) { if (!feedDef(id)) return; + if (feedDef(id).of) loadOfFollows(feedDef(id)); if (id !== curFeedId) { curFeedId = id; if (!adhocFeeds.has(id)) { try { localStorage.setItem(FEED_LS, id); } catch {} } @@ -4344,19 +4374,9 @@ export function messagesFeature(ctx) { const id = 'of:' + pk + (readFrom.length ? '@' + readFrom.join(',') : ''); let def = adhocFeeds.get(id); if (!def) { - def = { id, name: '', follows: false, authors: [], packs: [], topics: [], of: pk, ofAt: 0, relays: readFrom }; + def = { id, name: '', follows: false, authors: [], packs: [], topics: [], of: pk, relays: readFrom }; adhocFeeds.set(id, def); - liveProfileOf(pk); // their name and face for the title - queryOn([...new Set([...relays, ...PROFILE_RELAYS, ...NOTE_RELAYS])], { kinds: [3], authors: [pk] }, 6000) - .catch(() => []) - .then((evs) => { - const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0]; - def.authors = newest ? hexList(newest.tags.filter((x) => x[0] === 'p').map((x) => x[1])) : []; - def.ofAt = Date.now(); - dropFeedState(id); // whatever was built before the list was known - if (curFeedId === id) selectFeed(id); - scheduleRepaint(); - }); + loadOfFollows({ ...def, relays: [...relays, ...readFrom] }); } showFeed(id); } @@ -7682,7 +7702,7 @@ export function messagesFeature(ctx) { onClick: () => switchFeed(f.id), // someone's timeline carries the person, not a name — an empty // label left a blank black pill - }, f.of ? t('feedOfTitle', { name: displayName(f.of) }) : f.name || feedSummary(f))), + }, f.name || (f.of ? t('feedOfTitle', { name: displayName(f.of) }) : feedSummary(f)))), h('button', { class: 'feed-chip add', type: 'button', title: t('feedNew'), 'aria-label': t('feedNew'), onClick: () => openFeedEditor(null) }, '+')); } // The sheets a post (or a person) can open: the ⋯ menu, the reaction @@ -7703,7 +7723,9 @@ export function messagesFeature(ctx) { item('\u{1F4E1}', t('feedRelays') + (feedRelays(def) ? ' \u00b7 ' + feedRelays(def).length : ''), () => { ui.feedRelayEdit = { id: def.id, input: '' }; }), def.builtin || visitor ? null : adhocFeeds.has(def.id) - ? item('\u{1F4BE}', t('feedSaveAdhoc'), () => openFeedEditor(def)) + ? item('\u{1F4BE}', t('feedSaveAdhoc'), () => (def.of ? saveTimelineFeed(def) : openFeedEditor(def))) + // a saved timeline is the person, nothing to edit — only to drop + : def.of ? item('\u{1F5D1}', t('feedDelete'), () => deleteFeed(def.id)) : item('\u270e', t('feedEdit'), () => openFeedEditor(def)), h('button', { class: 'btn-ghost btn-block', onClick: close }, t('back')))); } @@ -7853,7 +7875,7 @@ export function messagesFeature(ctx) { }), def.of ? avatar(def.of, 'chat-avatar mini', true) : null, h('h3', { style: 'margin:0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, - def.builtin && !def.all ? t('feedTitle') : def.of ? t('feedOfTitle', { name: displayName(def.of) }) : def.name), + def.builtin && !def.all ? t('feedTitle') : def.of && !def.name ? t('feedOfTitle', { name: displayName(def.of) }) : def.name), // one ⋯ for the feed itself (share, relays, save or edit) and one // pencil for a new post: the title keeps the room a phone has h('button', { @@ -7867,7 +7889,7 @@ export function messagesFeature(ctx) { ui.feedRelayEdit && ui.feedRelayEdit.id === def.id ? relayPanel(def) : null, visitor ? null : feedChips(), visitor ? null : postComposer(), - def.of && !def.ofAt + def.of && !ofLoaded(def) ? h('div', { class: 'row gap6', style: 'justify-content:center;align-items:center;padding:12px 0' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('feedOfLoading'))) : def.of && !hasQuery @@ -7953,6 +7975,22 @@ export function messagesFeature(ctx) { deleteFollowSet(prev).catch(() => {}); } } + // "Save feed" on someone's timeline: into the chips at once. The editor + // would have copied their whole follow list in, one row per person. + function saveTimelineFeed(adhoc) { + const s = st(); + const have = s.feeds.find((f) => f.of === adhoc.of && JSON.stringify(f.relays || []) === JSON.stringify(adhoc.relays || [])); + const id = have ? have.id : 'f' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); + if (!have) { + s.feeds.push({ id, name: '', follows: false, authors: [], packs: [], topics: [], of: adhoc.of, + ...(adhoc.relays && adhoc.relays.length ? { relays: adhoc.relays } : {}), at: Date.now() }); + save(s); + } + adhocFeeds.delete(adhoc.id); + feedStates.delete(adhoc.id); + switchFeed(id); + toast(t('feedSaved')); + } function deleteFeed(id) { const s = st(); const def = s.feeds.find((f) => f.id === id); diff --git a/src/i18n.js b/src/i18n.js index 02c60aa..afc76ef 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -242,6 +242,7 @@ const STR = { feedTopicsHelp: 'Posts tagged with any of these. With people set as well, only their posts on these topics.', feedDelete: 'Delete feed', feedSaveAdhoc: 'Save feed', + feedSaved: 'Feed saved', feedNoQuery: 'Add people or topics to this feed.', feedOfTitle: '{name}’s timeline', feedOfLoading: 'Reading who they follow…', diff --git a/tools/avatar-profile-test.js b/tools/avatar-profile-test.js index 0f59e10..6e84429 100644 --- a/tools/avatar-profile-test.js +++ b/tools/avatar-profile-test.js @@ -76,6 +76,20 @@ try { await sleep(1500); const chip = await page.evaluate(() => document.querySelector('.feed-chip.on')?.textContent || null); check('someone’s timeline has a labelled chip', !!chip && /Bob Poster/.test(chip), JSON.stringify(chip)); + + // ⋯ → Save feed: straight into the chips, no editor listing their follows + await page.evaluate((l) => document.querySelector('button[aria-label="' + l + '"]').click(), 'Feed options'); + await sleep(500); + const saved = await click('save feed'); + await sleep(1000); + const savedState = await page.evaluate(() => ({ editor: /New feed/.test(document.body.innerText), chips: [...document.querySelectorAll('.feed-chip')].map((c) => c.textContent), on: document.querySelector('.feed-chip.on')?.textContent })); + check('“Save feed” on a timeline saves it without the editor', saved && !savedState.editor && /Bob Poster/.test(savedState.on || ''), JSON.stringify(savedState)); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click()); + await sleep(2000); + const kept = await page.evaluate(() => [...document.querySelectorAll('.feed-chip')].map((c) => c.textContent)); + check('...and it is still among the feeds after a reload', kept.some((c) => /(Bob Poster|npub1hwam).*’s timeline/.test(c)), JSON.stringify(kept)); } finally { await browser.close(); server.stop(true); } console.log(ok ? '\n✅ a face opens its profile at the top' : '\n❌ failed'); process.exit(ok ? 0 : 1); -- cgit v1.2.3 From bdb8c3878df0439e3ebbcede5e2555f949a0cd7a Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 29 Sep 2026 08:33:28 -0700 Subject: Fix public media uploads and feed tab navigation --- src/app.js | 34 +++++++++------ src/features/messages.js | 14 ++++--- src/i18n.js | 8 ++-- src/locales/ar.json | 4 +- src/locales/bn.json | 4 +- src/locales/de.json | 4 +- src/locales/es.json | 4 +- src/locales/fr.json | 4 +- src/locales/hi.json | 4 +- src/locales/ja.json | 4 +- src/locales/ko.json | 4 +- src/locales/pt.json | 4 +- src/locales/ru.json | 4 +- src/locales/tr.json | 4 +- src/locales/zh.json | 4 +- src/media-upload.js | 86 ++++++++++++++++++++++++++++++++++++++ tools/feed-nav-destination-test.js | 18 ++++++++ tools/media-upload-test.js | 43 +++++++++++++++++++ 18 files changed, 206 insertions(+), 45 deletions(-) create mode 100644 src/media-upload.js create mode 100644 tools/feed-nav-destination-test.js create mode 100644 tools/media-upload-test.js (limited to 'src/i18n.js') diff --git a/src/app.js b/src/app.js index 4003e1b..203b50a 100644 --- a/src/app.js +++ b/src/app.js @@ -9,6 +9,7 @@ import { qrSvg } from './qr.js'; import { makeSearcher, resultRows, searchable, punkUrl, warmSearch } from './recipient-search.js'; import { npubOf, seedPubkey, neventOf } from './nostr.js'; import { nip98Header } from './nip98.js'; +import { uploadPublicMedia, MediaUploadError } from './media-upload.js'; import { NOSTR_MARK } from './features/nostrlogin.js'; import { scanQr } from './scan.js'; import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js'; @@ -3452,21 +3453,27 @@ async function onbUpload(file) { const fd = new FormData(); fd.append('file', file); const endpoint = 'https://nostr.build/api/v2/upload/files'; - // nostr.build no longer takes anonymous uploads — sign with the wallet's - // nostr key, the one key that's always available without a signer prompt. - const headers = {}; - try { - if (wallet.nostrPubkey && wallet.nostrPubkey()) { - headers.authorization = await nip98Header({ signEvent: (e) => wallet.nostrSign(e) }, endpoint, 'POST'); - } - } catch {} - const r = await fetch(endpoint, { method: 'POST', body: fd, headers }); - const j = await r.json(); - const url = j?.data?.[0]?.url; + const authorization = await nip98Header({ signEvent: (event) => wallet.nostrSign(event) }, endpoint, 'POST'); + const response = await fetch(endpoint, { + method: 'POST', body: fd, headers: { authorization }, signal: AbortSignal.timeout(60_000), + }); + let body = null; + try { body = await response.json(); } catch {} + const url = response.ok && body?.data?.[0]?.url; if (!url) throw new Error(t('onbUploadFailed')); return url; } +async function publicMediaUpload(file) { + try { + return await uploadPublicMedia(file, (event) => wallet.nostrSign(event)); + } catch (error) { + if (error instanceof MediaUploadError && error.code === 'too-big') throw new Error(t('mediaUploadTooBig')); + if (error instanceof MediaUploadError && error.code === 'unsupported') throw new Error(t('mediaUploadUnsupported')); + throw new Error(t('msgUploadFailed')); + } +} + // Whether the wizard can offer Spending at all: an Ark-capable build on a // network with the names registrar, for a full (signing) account. Elsewhere // the offer would be a spinner that never resolves. @@ -5720,9 +5727,10 @@ const ctx = { setAccount: (a, dir) => setAccountSel(a, dir), // Open (or create) a wallet from a mnemonic — used by nostr login. openMnemonic: async (mnemonic, passphrase, opts) => enterWallet(mnemonic, passphrase, opts), - // Upload an image (avatar) and get back its URL — the same signed - // nostr.build path the onboarding picker uses. + // Profile pictures retain the metadata-scrubbing image uploader. Public + // post media uses resilient Blossom storage and media-specific failures. uploadImage: (file) => onbUpload(file), + uploadMedia: (file) => publicMediaUpload(file), // A nostr login that lands mid-wizard: a restored wallet has been through // onboarding elsewhere, so the wizard ends without tour or prompts; a fresh // one falls through to the wallet + tour on the next render. diff --git a/src/features/messages.js b/src/features/messages.js index 7505dbf..fb88a56 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -6995,7 +6995,7 @@ export function messagesFeature(ctx) { // A reply can carry a picture too — same upload, same imeta tag, same // paperclip. The URL lands in the draft, which is what every client // reads as the media. - ctx.uploadImage ? h('button', { + (ctx.uploadMedia || ctx.uploadImage) ? h('button', { class: 'attach-btn', title: t('feedAttach'), 'aria-label': t('feedAttach'), disabled: !!ui.postUploading, onClick: () => document.getElementById('reply-file')?.click(), }, ui.postUploading ? h('span', { class: 'spinner sm' }) : h('span', { style: 'display:flex', html: CLIP })) : null, @@ -7588,10 +7588,11 @@ export function messagesFeature(ctx) { const composeText = () => (ui.profCompose == null ? draftFor(POST_DRAFT) : ui.profCompose || ''); // Upload, then hand the URL to whichever draft asked for it. async function attachTo(file, place) { - if (!file || !ctx.uploadImage) return; + const upload = ctx.uploadMedia || ctx.uploadImage; + if (!file || !upload) return; ui.postUploading = true; render(); try { - const url = await ctx.uploadImage(file); + const url = await upload(file); (ui.postMedia ||= []).push({ url, m: file.type || '' }); place(url); } catch (e) { toast(e.message || String(e)); } @@ -7667,7 +7668,7 @@ export function messagesFeature(ctx) { render(); } } }, t('profPostBtn')), - ctx.uploadImage ? h('button', { + (ctx.uploadMedia || ctx.uploadImage) ? h('button', { class: 'attach-btn', title: t('feedAttach'), 'aria-label': t('feedAttach'), disabled: !!ui.postUploading, onClick: () => document.getElementById('post-file')?.click(), }, ui.postUploading ? h('span', { class: 'spinner sm' }) : h('span', { style: 'display:flex', html: CLIP })) : null, @@ -7804,7 +7805,10 @@ export function messagesFeature(ctx) { class: 'app-bottom-nav', 'data-key': 'app-bottom-nav', }, button('feed', t('feedTitle'), '', () => { - if (active === 'feed' && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread && !ui.feedEdit) { jumpToNew(); return; } + // A destination tab is a destination: tapping Feed while already + // there always returns to its top. Only the explicit "x new posts" + // pill jumps to the first unread arrival. + if (active === 'feed' && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread && !ui.feedEdit) { glideToTop(); return; } leavePage(); if (visitor) { ui.pubProf = true; openFirehose(); } else showFeed(curFeedId); window.scrollTo({ top: 0 }); diff --git a/src/i18n.js b/src/i18n.js index afc76ef..475f919 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -198,7 +198,9 @@ const STR = { msgAttachFailed: 'Attachment unavailable', msgAttach: 'Attach a picture', msgAttachTooBig: 'That file is too big (20 MB max)', - msgUploadFailed: 'Upload failed. Try again', + msgUploadFailed: 'Upload failed. Check your connection and try again', + mediaUploadTooBig: 'That file is too big for the available media hosts', + mediaUploadUnsupported: 'That file format is not supported', msgPhoto: 'Photo', searchPeopleFor: 'Search people for {q}', msgEmojiSearch: 'Search emoji…', @@ -1006,9 +1008,9 @@ const STR = { onbNameWait: 'Getting things ready…', onbNotNow: 'Not now', onbAvatarTitle: 'Choose your look', - onbAvatarBody: 'Pick a punk or upload your own picture. You can change it anytime.', + onbAvatarBody: 'Choose an avatar or upload your own picture. You can change it anytime.', onbUpload: 'Upload a photo', - onbUploadFailed: 'Upload failed. Try a punk for now', + onbUploadFailed: 'Upload failed. Try another picture', onbProfileSkipped: 'Couldn’t publish your profile. Set it later from Edit profile', onbContinue: 'Continue', onbSkipAvatar: 'Continue with my default', diff --git a/src/locales/ar.json b/src/locales/ar.json index 6763c56..2e914c3 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -616,9 +616,9 @@ "onbNameWait": "جهز الأشياء", "onbNotNow": "ليس الآن", "onbAvatarTitle": "اختر مظهرك", - "onbAvatarBody": "إلتقط قطعة أو ارفع صورتك يمكنك تغييره في أي وقت", + "onbAvatarBody": "اختر صورة رمزية أو ارفع صورتك. يمكنك تغييرها في أي وقت", "onbUpload": "تحميل الصورة", - "onbUploadFailed": "لقد فشل التحميل - حاول أن تفشل الآن", + "onbUploadFailed": "فشل الرفع. جرّب صورة أخرى", "onbProfileSkipped": "لم يتمكن من نشر ملفك - وضعه في وقت لاحق من ملف إديت", "onbContinue": "استمر", "onbSkipAvatar": "استمر في إخفاقي", diff --git a/src/locales/bn.json b/src/locales/bn.json index 8c52809..44b2700 100644 --- a/src/locales/bn.json +++ b/src/locales/bn.json @@ -616,9 +616,9 @@ "onbNameWait": "জিনিস প্রস্তুত করা হচ্ছে...", "onbNotNow": "এই মুহূর্তে নয়", "onbAvatarTitle": "আপনার চেহারা পছন্দ করুন", - "onbAvatarBody": "তোমার নিজের ছবি আপলোড করে নাও। তুমি যেকোন সময় এটাকে পরিবর্তন করতে পারো।", + "onbAvatarBody": "একটি অবতার বেছে নাও অথবা নিজের ছবি আপলোড করো। যেকোনো সময় এটি বদলাতে পারো।", "onbUpload": "একটি ছবি আপলোড করুন", - "onbUploadFailed": "আপলোড করতে ব্যর্থ - এখন একটা বাজে প্রচেষ্টা করুন", + "onbUploadFailed": "আপলোড ব্যর্থ হয়েছে। অন্য একটি ছবি চেষ্টা করো", "onbProfileSkipped": "প্রোফাইল সম্পাদনা করতে ব্যর্থ - পরে নির্ধারণ করা সম্ভব নয়", "onbContinue": "এগিয়ে চলুন", "onbSkipAvatar": "ডিফল্ট হিসেবে নির্দিষ্ট করো", diff --git a/src/locales/de.json b/src/locales/de.json index 6409d81..bf827b4 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -616,9 +616,9 @@ "onbNameWait": "Bereitmachen...", "onbNotNow": "Nicht jetzt", "onbAvatarTitle": "Wählen Sie Ihren Look", - "onbAvatarBody": "Wählen Sie einen Punk oder laden Sie Ihr eigenes Bild hoch. Sie können es jederzeit ändern.", + "onbAvatarBody": "Wählen Sie einen Avatar oder laden Sie Ihr eigenes Bild hoch. Sie können ihn jederzeit ändern.", "onbUpload": "Foto hochladen", - "onbUploadFailed": "Upload fehlgeschlagen Versuchen Sie einen Punk für jetzt", + "onbUploadFailed": "Upload fehlgeschlagen. Versuchen Sie ein anderes Bild", "onbProfileSkipped": "Konnte dein Profil nicht publizieren", "onbContinue": "Weiter", "onbSkipAvatar": "Weiter mit meinem Standard", diff --git a/src/locales/es.json b/src/locales/es.json index 6cf00b6..52e417b 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -616,9 +616,9 @@ "onbNameWait": "Preparando las cosas...", "onbNotNow": "Ahora no.", "onbAvatarTitle": "Elige tu look", - "onbAvatarBody": "Escoge un punk o sube tu propia imagen. Puedes cambiarlo en cualquier momento.", + "onbAvatarBody": "Escoge un avatar o sube tu propia imagen. Puedes cambiarlo en cualquier momento.", "onbUpload": "Sube una foto", - "onbUploadFailed": "Subir falló, probar un punk por ahora", + "onbUploadFailed": "La subida falló. Prueba con otra imagen", "onbProfileSkipped": "No se pudo publicar su perfil - establecerlo más tarde del perfil Editar", "onbContinue": "Continuar", "onbSkipAvatar": "Continuar con mi defecto", diff --git a/src/locales/fr.json b/src/locales/fr.json index 9dd56c0..471eb5b 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -616,9 +616,9 @@ "onbNameWait": "Préparer les choses...", "onbNotNow": "Pas maintenant.", "onbAvatarTitle": "Choisissez votre look", - "onbAvatarBody": "Choisissez un punk ou téléchargez votre propre photo. Tu peux le changer à tout moment.", + "onbAvatarBody": "Choisissez un avatar ou téléchargez votre propre photo. Vous pouvez le changer à tout moment.", "onbUpload": "Télécharger une photo", - "onbUploadFailed": "Télécharger échoué, essayez un punk pour l'instant", + "onbUploadFailed": "Le téléversement a échoué. Essayez une autre image", "onbProfileSkipped": "Impossible de publier votre profil, définissez-le plus tard à partir du profil Édition", "onbContinue": "Continuer", "onbSkipAvatar": "Continuer avec mon défaut", diff --git a/src/locales/hi.json b/src/locales/hi.json index be3e6a3..4fcd47d 100644 --- a/src/locales/hi.json +++ b/src/locales/hi.json @@ -616,9 +616,9 @@ "onbNameWait": "चीजों को तैयार करना", "onbNotNow": "अब नहीं", "onbAvatarTitle": "अपना लुक चुनें", - "onbAvatarBody": "एक पंक चुनें या अपनी खुद की तस्वीर अपलोड करें। आप इसे कभी भी बदल सकते हैं।", + "onbAvatarBody": "एक अवतार चुनें या अपनी तस्वीर अपलोड करें। आप इसे कभी भी बदल सकते हैं।", "onbUpload": "फ़ोटो अपलोड करें", - "onbUploadFailed": "असफल रहा - अब के लिए एक पंक का प्रयास करें", + "onbUploadFailed": "अपलोड विफल रहा। कोई दूसरी तस्वीर आज़माएँ", "onbProfileSkipped": "अपने प्रोफ़ाइल को प्रकाशित नहीं कर सका - इसे बाद में संपादित प्रोफ़ाइल से सेट करें", "onbContinue": "जारी", "onbSkipAvatar": "जारी रखें मेरी डिफ़ॉल्ट के साथ", diff --git a/src/locales/ja.json b/src/locales/ja.json index d9cd967..2ab8916 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -616,9 +616,9 @@ "onbNameWait": "物事を準備する...", "onbNotNow": "今は今", "onbAvatarTitle": "お問い合わせ", - "onbAvatarBody": "パンクを選ぶか、自分の写真をアップロードしてください。 いつでも変更できます。", + "onbAvatarBody": "アバターを選ぶか、自分の写真をアップロードしてください。いつでも変更できます。", "onbUpload": "写真をアップロードする", - "onbUploadFailed": "アップロード失敗, 今すぐパンクを試してみる", + "onbUploadFailed": "アップロードに失敗しました。別の画像をお試しください", "onbProfileSkipped": "プロフィールを公開できません。編集プロファイルから後で設定できます。", "onbContinue": "お問い合わせ", "onbSkipAvatar": "デフォルトで続ける", diff --git a/src/locales/ko.json b/src/locales/ko.json index 6413666..e125415 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -615,9 +615,9 @@ "onbNameWait": "준비하는 것들 ...", "onbNotNow": "현재 위치", "onbAvatarTitle": "자주 묻는 질문", - "onbAvatarBody": "펑크를 선택하거나 자신의 사진을 업로드하십시오. 언제든지 변경할 수 있습니다.", + "onbAvatarBody": "아바타를 선택하거나 사진을 업로드하세요. 언제든지 변경할 수 있습니다.", "onbUpload": "사진 업로드", - "onbUploadFailed": "업로드 실패, 지금 punk 시도", + "onbUploadFailed": "업로드하지 못했습니다. 다른 이미지를 사용해 보세요", "onbProfileSkipped": "프로필을 게시 할 수 없습니다 - 나중에 편집 프로필에서 설정", "onbContinue": "계속하기", "onbSkipAvatar": "기본 설정", diff --git a/src/locales/pt.json b/src/locales/pt.json index 8c36922..9f5b2ad 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -616,9 +616,9 @@ "onbNameWait": "Preparando as coisas...", "onbNotNow": "Agora não.", "onbAvatarTitle": "Escolha o seu look", - "onbAvatarBody": "Escolha um punk ou carregue a sua própria foto. Podes mudá-lo quando quiseres.", + "onbAvatarBody": "Escolha um avatar ou carregue a sua própria foto. Podes mudá-lo quando quiseres.", "onbUpload": "Enviar uma foto", - "onbUploadFailed": "O envio falhou, tente um punk por enquanto", + "onbUploadFailed": "O envio falhou. Tente outra imagem", "onbProfileSkipped": "Não foi possível publicar o seu perfil, defini-lo mais tarde a partir de Editar perfil", "onbContinue": "Continuar", "onbSkipAvatar": "Continuar com o meu padrão", diff --git a/src/locales/ru.json b/src/locales/ru.json index 7ea2c0f..4e472fc 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -616,9 +616,9 @@ "onbNameWait": "Готовить вещи...", "onbNotNow": "Не сейчас", "onbAvatarTitle": "Выберите свой взгляд", - "onbAvatarBody": "Выберите панк или загрузите свою фотографию. Вы можете изменить его в любое время.", + "onbAvatarBody": "Выберите аватар или загрузите свою фотографию. Его можно изменить в любое время.", "onbUpload": "Загрузить фото", - "onbUploadFailed": "Загрузить не удалось, попробуйте панк на данный момент", + "onbUploadFailed": "Не удалось загрузить. Попробуйте другое изображение", "onbProfileSkipped": "Не смог опубликовать свой профиль, установите его позже из профиля Edit", "onbContinue": "Продолжать", "onbSkipAvatar": "Продолжить мой дефолт", diff --git a/src/locales/tr.json b/src/locales/tr.json index 1f261b7..e1e9928 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -615,9 +615,9 @@ "onbNameWait": "İşler hazır olsun...", "onbNotNow": "Şimdi Değil", "onbAvatarTitle": "görünümünü seçin", - "onbAvatarBody": "Bir punk seçin veya kendi resmini yükleyin. Her zaman değiştirebilirsiniz.", + "onbAvatarBody": "Bir avatar seçin veya kendi resminizi yükleyin. İstediğiniz zaman değiştirebilirsiniz.", "onbUpload": "Bir fotoğraf", - "onbUploadFailed": "Yükleme başarısız oldu - şimdilik bir punk deneyin", + "onbUploadFailed": "Yükleme başarısız oldu. Başka bir resim deneyin", "onbProfileSkipped": "Profilinizi yayınlayamaz - daha sonra Edit profilinden ayarlayın", "onbContinue": "Devam etmeye devam et", "onbSkipAvatar": "Benim varsayılanimle devam et", diff --git a/src/locales/zh.json b/src/locales/zh.json index 04748fc..6bb4c50 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -616,9 +616,9 @@ "onbNameWait": "准备...", "onbNotNow": "现在不行", "onbAvatarTitle": "选择你的外观", - "onbAvatarBody": "选择一个朋克或上传自己的相片. 你可以随时改变", + "onbAvatarBody": "选择一个头像或上传自己的照片。你可以随时更改", "onbUpload": "上传相片", - "onbUploadFailed": "上传失败,, 暂时尝试一个朋克", + "onbUploadFailed": "上传失败。请尝试其他图片", "onbProfileSkipped": "无法发布您的配置文件, 稍后从编辑配置文件中设置 。", "onbContinue": "继续", "onbSkipAvatar": "继续我的默认", diff --git a/src/media-upload.js b/src/media-upload.js new file mode 100644 index 0000000..686fec5 --- /dev/null +++ b/src/media-upload.js @@ -0,0 +1,86 @@ +import { sha256 } from '@noble/hashes/sha256'; +import { bytesToHex } from '@noble/hashes/utils'; + +// Independent public hosts: a phone upload should not fail just because one +// provider is down or rejects a particular container. nostr.download's NIP-96 +// document currently advertises large image/video/audio uploads; nostr.build +// is the second, widely-used Blossom home. +export const PUBLIC_MEDIA_SERVERS = ['https://nostr.download', 'https://blossom.nostr.build']; + +export class MediaUploadError extends Error { + constructor(code, details = '') { + super(code); + this.name = 'MediaUploadError'; + this.code = code; + this.details = details; + } +} + +async function fileHash(file) { + const hash = sha256.create(); + const chunk = 4 * 1024 * 1024; + for (let at = 0; at < file.size; at += chunk) + hash.update(new Uint8Array(await file.slice(at, at + chunk).arrayBuffer())); + return bytesToHex(hash.digest()); +} + +const responseUrl = (body) => body?.url + || body?.data?.[0]?.url + || (body?.tags || []).find((x) => x?.[0] === 'url')?.[1] + || null; + +function mediaUrl(url, server, file) { + const out = new URL(url, server); + const tail = out.pathname.split('/').at(-1) || ''; + if (/^[0-9a-f]{64}$/i.test(tail)) { + const named = String(file.name || '').match(/\.([a-z0-9]{1,8})$/i)?.[1]; + const byType = { 'video/quicktime': 'mov', 'video/mp4': 'mp4', 'video/webm': 'webm', + 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif', 'image/avif': 'avif' }[file.type]; + const ext = (named || byType || '').toLowerCase(); + if (ext) out.pathname += '.' + ext; + } + return out.href; +} + +// BUD-02 upload. `signEvent` is deliberately injected: the app can use its +// always-available wallet identity, while tests need no secret key. +export async function uploadPublicMedia(file, signEvent, { + servers = PUBLIC_MEDIA_SERVERS, + fetcher = globalThis.fetch, + timeoutMs = 5 * 60_000, +} = {}) { + if (!file || !file.size || typeof signEvent !== 'function') throw new MediaUploadError('failed'); + const x = await fileHash(file); + const now = Math.floor(Date.now() / 1000); + const auth = await signEvent({ + kind: 24242, created_at: now - 5, content: 'upload', + tags: [['t', 'upload'], ['x', x], ['expiration', String(now + Math.ceil(timeoutMs / 1000) + 120)]], + }); + if (!auth) throw new MediaUploadError('failed'); + const authorization = 'Nostr ' + btoa(JSON.stringify(auth)); + const failures = []; + for (const server of servers) { + try { + const response = await fetcher(server.replace(/\/$/, '') + '/upload', { + method: 'PUT', body: file, signal: AbortSignal.timeout(timeoutMs), + headers: { + authorization, + 'content-type': file.type || 'application/octet-stream', + 'x-sha-256': x, + 'x-content-length': String(file.size), + }, + }); + let body = null; + try { body = await response.json(); } catch {} + const url = response.ok && responseUrl(body); + if (url) return mediaUrl(url, server, file); + failures.push({ status: response.status, reason: response.headers.get('x-reason') || body?.message || '' }); + } catch (error) { + failures.push({ status: 0, reason: error?.message || '' }); + } + } + const details = failures.map((x) => [x.status, x.reason].filter(Boolean).join(' ')).filter(Boolean).join('; '); + if (failures.length && failures.every((x) => x.status === 413)) throw new MediaUploadError('too-big', details); + if (failures.length && failures.every((x) => x.status === 415)) throw new MediaUploadError('unsupported', details); + throw new MediaUploadError('failed', details); +} diff --git a/tools/feed-nav-destination-test.js b/tools/feed-nav-destination-test.js new file mode 100644 index 0000000..b14a124 --- /dev/null +++ b/tools/feed-nav-destination-test.js @@ -0,0 +1,18 @@ +// The Feed destination and the new-posts pill deliberately have different +// navigation semantics. Keep this small source-level guard beside the fuller +// browser test for the pill's landing position. +import assert from 'node:assert/strict'; + +const source = await Bun.file('src/features/messages.js').text(); +const navStart = source.indexOf("button('feed', t('feedTitle')"); +const navEnd = source.indexOf("button('messages',", navStart); +const feedButton = source.slice(navStart, navEnd); +assert(navStart > 0 && navEnd > navStart, 'Feed bottom-nav handler exists'); +assert(feedButton.includes('glideToTop()'), 'Feed bottom-nav button returns to the top'); +assert(!feedButton.includes('jumpToNew()'), 'Feed bottom-nav button does not jump to unread posts'); + +const pillStart = source.indexOf("class: 'feed-new-pill'"); +const pill = source.slice(pillStart, source.indexOf('})', pillStart) + 2); +assert(pillStart > 0 && pill.includes('jumpToNew()'), 'new-posts pill jumps to the first unread arrival'); + +console.log('✓ Feed tab goes to the top; only the new-posts pill jumps to unread posts'); diff --git a/tools/media-upload-test.js b/tools/media-upload-test.js new file mode 100644 index 0000000..f9cc56a --- /dev/null +++ b/tools/media-upload-test.js @@ -0,0 +1,43 @@ +import { uploadPublicMedia, MediaUploadError } from '../src/media-upload.js'; +import { sha256 } from '@noble/hashes/sha256'; +import { bytesToHex } from '@noble/hashes/utils'; + +let ok = true; +const check = (name, pass) => { console.log(`${pass ? '✓' : '✗'} ${name}`); if (!pass) ok = false; }; +const bytes = new TextEncoder().encode('phone video bytes'); +const file = new File([bytes], 'clip.mov', { type: 'video/quicktime' }); +let signed = null; +const sign = async (event) => (signed = { ...event, id: '1'.repeat(64), pubkey: '2'.repeat(64), sig: '3'.repeat(128) }); +const calls = []; +const fetcher = async (url, init) => { + calls.push({ url, init }); + if (url.startsWith('https://first')) return new Response(JSON.stringify({ message: 'temporarily unavailable' }), { status: 503 }); + return new Response(JSON.stringify({ url: 'https://cdn.example/clip.mov' }), { status: 201, headers: { 'content-type': 'application/json' } }); +}; + +const url = await uploadPublicMedia(file, sign, { servers: ['https://first', 'https://second/'], fetcher, timeoutMs: 1000 }); +check('falls back to a second independent media host', url === 'https://cdn.example/clip.mov' && calls.length === 2); +check('uploads the original video type and bytes', calls[1].init.method === 'PUT' && calls[1].init.body === file + && calls[1].init.headers['content-type'] === 'video/quicktime'); +check('hashes the file and binds the hash into Blossom auth', calls[1].init.headers['x-sha-256'] === bytesToHex(sha256(bytes)) + && signed.tags.some((tag) => tag[0] === 'x' && tag[1] === calls[1].init.headers['x-sha-256'])); +check('sends signed Blossom authorization', calls[1].init.headers.authorization.startsWith('Nostr ')); + +const bareHashUrl = await uploadPublicMedia(file, sign, { + servers: ['https://media.example'], timeoutMs: 1000, + fetcher: async () => new Response(JSON.stringify({ url: '/' + 'a'.repeat(64) }), { status: 201 }), +}); +check('adds the video extension when a Blossom server returns a bare hash URL', bareHashUrl.endsWith('.mov')); + +try { + await uploadPublicMedia(file, sign, { + servers: ['https://one', 'https://two'], timeoutMs: 1000, + fetcher: async () => new Response('', { status: 413 }), + }); + check('reports provider size rejection', false); +} catch (error) { + check('reports provider size rejection', error instanceof MediaUploadError && error.code === 'too-big'); +} + +console.log(ok ? '\n✅ public media upload works' : '\n❌ public media upload failed'); +process.exit(ok ? 0 : 1); -- cgit v1.2.3 From 62b8e37109464aca875e306269c181faf6c64ae3 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 29 Sep 2026 08:37:25 -0700 Subject: Confirm discarding substantial post drafts --- src/features/messages.js | 26 +++++++++++++++++++++++++- src/i18n.js | 4 ++++ tools/post-cancel-test.js | 7 +++++-- 3 files changed, 34 insertions(+), 3 deletions(-) (limited to 'src/i18n.js') diff --git a/src/features/messages.js b/src/features/messages.js index 10773c8..89fe55a 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -7627,12 +7627,35 @@ export function messagesFeature(ctx) { }; function cancelPost() { postAttachGeneration++; + ui.postCancelConfirm = false; ui.profCompose = null; ui.postMedia = []; ui.postPreview = false; discardDraft(POST_DRAFT); render(); } + function requestCancelPost() { + if (composeText().length > 20 || ui.postUploading || (ui.postMedia || []).length) { + ui.postCancelConfirm = true; + render(); + return; + } + cancelPost(); + } + function postCancelWarning() { + if (!ui.postCancelConfirm) return null; + const keep = () => { ui.postCancelConfirm = false; render(); }; + return h('div', { + class: 'confirm-pop-backdrop', + onClick: (event) => { if (event.target === event.currentTarget) keep(); }, + }, + h('div', { class: 'card col confirm-pop', style: 'gap:10px' }, + h('h3', { style: 'margin:0' }, t('postDiscardTitle')), + h('div', { class: 'small muted' }, t('postDiscardBody')), + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-ghost grow', onClick: keep }, t('postKeepEditing')), + h('button', { class: 'btn-danger grow', onClick: cancelPost }, t('postDiscard'))))); + } const CLIP = ''; const EYE = ''; @@ -7703,7 +7726,8 @@ export function messagesFeature(ctx) { type: 'file', id: 'post-file', accept: 'image/*,video/*', style: 'display:none', onChange: async (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; await attachMedia(f); }, }), - h('button', { class: 'btn-ghost', onClick: cancelPost }, t('cancel')))); + h('button', { class: 'btn-ghost', onClick: requestCancelPost }, t('cancel'))), + postCancelWarning()); } // ---- the feed view -------------------------------------------------------- diff --git a/src/i18n.js b/src/i18n.js index 475f919..41ccb36 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -365,6 +365,10 @@ const STR = { postBoostedAlready: 'You already boosted that', postQuote: 'Quote', postMore: 'More', + postDiscardTitle: 'Discard this draft?', + postDiscardBody: 'You’ll lose this draft and any attachments.', + postKeepEditing: 'Keep editing', + postDiscard: 'Discard draft', postMute: 'Mute', postUnmute: 'Unmute', postMuted: 'Muted. Their posts are hidden', diff --git a/tools/post-cancel-test.js b/tools/post-cancel-test.js index 5527bf2..217e743 100644 --- a/tools/post-cancel-test.js +++ b/tools/post-cancel-test.js @@ -12,6 +12,9 @@ assert(cancel.includes('ui.profCompose = null'), 'composer is closed'); assert(cancel.includes('ui.postMedia = []'), 'pending attachment metadata is removed'); assert(cancel.includes('ui.postPreview = false'), 'preview state is removed'); assert(cancel.includes('discardDraft(POST_DRAFT)'), 'persisted draft is removed immediately'); -assert(source.includes("onClick: cancelPost }, t('cancel')"), 'Cancel button uses the discard handler'); +assert(source.includes("composeText().length > 20"), 'drafts over 20 characters require confirmation'); +assert(source.includes("ui.postUploading || (ui.postMedia || []).length"), 'attachments require confirmation'); +assert(source.includes("onClick: requestCancelPost }, t('cancel')"), 'Cancel button uses the guarded discard handler'); +assert(source.includes("onClick: cancelPost }, t('postDiscard')"), 'warning can confirm the discard'); -console.log('✓ Cancel closes the new-post form and removes its complete saved draft'); +console.log('✓ Cancel warns for substantial drafts, then closes the form and removes its complete saved draft'); -- cgit v1.2.3 From 505cd84813a80e0a69b2cc95aa93c68cf2856cb2 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Wed, 30 Sep 2026 10:10:10 -0700 Subject: Videos are shrunk on the phone before upload; nostr.build is the first media host. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No public Blossom host transcodes (nostr.build's and primal's BUD-05 /media return the same bitrate), so a 15-20 Mbps phone clip stalled on playback. src/video-compress.js re-encodes on the device with WebCodecs via Mediabunny (a lazily loaded chunk, left out of the SW precache and the standalone file): H.264, short edge 720, ~2.5 Mbps, moov first, audio passed through, rotation baked in. Anything short of a clear win uploads the original. A toast counts the shrinking down. Media hosts: blossom.nostr.build first, nostr.download second (it was unreachable for hours); a HEAD probe skips a host that doesn't answer within 5 s instead of waiting out the whole upload timeout. video-compress-test: 1080p 20 Mbps 5 s (landscape and rotated portrait) → 1.6 MB H.264 720p 2.49 Mbps with audio, moov first, in 2-3 s. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp --- build.js | 13 +++++- bun.lock | 7 +++ package.json | 1 + src/app.js | 11 +++++ src/i18n.js | 2 + src/media-upload.js | 17 +++++--- src/video-compress.js | 54 +++++++++++++++++++++++ tools/media-upload-test.js | 18 ++++++++ tools/video-compress-test.js | 101 +++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 217 insertions(+), 7 deletions(-) create mode 100644 src/video-compress.js create mode 100644 tools/video-compress-test.js (limited to 'src/i18n.js') diff --git a/build.js b/build.js index c90e527..2bc5361 100644 --- a/build.js +++ b/build.js @@ -332,7 +332,13 @@ async function bundleApp({ minify, features, staging, splitting = false }) { // The split build emits real ESM chunks (deferred features arrive as their // own files); the single-file build inlines the dynamic imports instead. ...(splitting ? { splitting: true, format: 'esm', naming: { chunk: 'chunk-[hash].[ext]' } } : {}), - plugins: [featurePlugin(enabledFeatures(features)), flagsPlugin(staging)], + plugins: [featurePlugin(enabledFeatures(features)), flagsPlugin(staging), + // the single file inlines every dynamic import: the video compressor + // (Mediabunny, ~700 KB) would ride along in a file meant to be small + // and offline — there it uploads the original, as before + ...(splitting ? [] : [{ name: 'no-video-compress', setup(build) { + build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' })); + } }])], }); if (!result.success) { for (const log of result.logs) console.error(log); @@ -470,7 +476,10 @@ if (import.meta.main) { const nwcSw = feats.includes('nwc') && feats.includes('ark'); const swBody = SW .replaceAll('{{VERSION}}', version) - .replaceAll('{{EXTRA_SHELL}}', chunks.map((c) => `, '${c.name}'`).join('')) + // precached for offline use — except what only works online anyway: + // the video compressor (Mediabunny, ~700 KB) loads when a video is + // picked for upload, not on every install + .replaceAll('{{EXTRA_SHELL}}', chunks.filter((c) => !c.text.includes('Mediabunny Codec Registry')).map((c) => `, '${c.name}'`).join('')) .replaceAll('{{NWC_WAKE_FALLBACK}}', nwcSw ? '' : SW_WAKE_ONLY); await Bun.write('dist/sw.js', nwcSw ? swBody + '\n' + await buildSwNwc() : swBody); for (const f of STATIC) await Bun.write('dist/' + f, Bun.file('static/' + f)); diff --git a/bun.lock b/bun.lock index 658bc63..10e82e2 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "@scure/bip39": "^1.6.0", "@scure/btc-signer": "^1.8.1", "jsqr": "^1.4.0", + "mediabunny": "^1.61.0", "nostr-tools": "^2.23.5", "qrcode-generator": "^2.0.4", }, @@ -43,6 +44,10 @@ "@scure/btc-signer": ["@scure/btc-signer@1.8.1", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5", "micro-packed": "~0.7.3" } }, "sha512-8nX9T++dFyKpvqksNHfSi9CgRsGnHAQtCdIQ1y1GmbCGLpV97v4MUyemUUT6uDumKL3oo3m4niyY6A32nmdLuQ=="], + "@types/dom-mediacapture-transform": ["@types/dom-mediacapture-transform@0.1.12", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw=="], + + "@types/dom-webcodecs": ["@types/dom-webcodecs@0.1.13", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -67,6 +72,8 @@ "jsqr": ["jsqr@1.4.0", "", {}, "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="], + "mediabunny": ["mediabunny@1.61.0", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-WciPOYwKZwgYkoBKcEK6JzzIf+w5whypPzFdOihOylZ/5Qv19FmNfRnTcaprH+jD136YpEASU30Eb6VNCJiz3A=="], + "micro-packed": ["micro-packed@0.7.3", "", { "dependencies": { "@scure/base": "~1.2.5" } }, "sha512-2Milxs+WNC00TRlem41oRswvw31146GiSaoCT7s3Xi2gMUglW5QBeqlQaZeHr5tJx9nm3i57LNXPqxOOaWtTYg=="], "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], diff --git a/package.json b/package.json index 6783bf9..1d12b14 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@scure/bip39": "^1.6.0", "@scure/btc-signer": "^1.8.1", "jsqr": "^1.4.0", + "mediabunny": "^1.61.0", "nostr-tools": "^2.23.5", "qrcode-generator": "^2.0.4" }, diff --git a/src/app.js b/src/app.js index 0bf3086..5b0bdb6 100644 --- a/src/app.js +++ b/src/app.js @@ -3483,6 +3483,17 @@ async function onbUpload(file) { } async function publicMediaUpload(file) { + // a phone video is shrunk on the device first (see video-compress.js); + // the toast counts it down, since a long clip takes a while + if (/^video\//.test(file?.type || '')) { + let shown = -1; + const { compressVideo } = await import('./video-compress.js'); + file = await compressVideo(file, (p) => { + const pct = Math.floor(p * 100); + if (pct !== shown && pct % 5 === 0) { shown = pct; toast(t('mediaShrinking', { pct }), 60_000); } + }); + if (shown >= 0) toast(t('mediaUploading'), 4000); + } try { return await uploadPublicMedia(file, (event) => wallet.nostrSign(event)); } catch (error) { diff --git a/src/i18n.js b/src/i18n.js index 41ccb36..f54e760 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -245,6 +245,8 @@ const STR = { feedDelete: 'Delete feed', feedSaveAdhoc: 'Save feed', feedSaved: 'Feed saved', + mediaShrinking: 'Shrinking the video for streaming… {pct}%', + mediaUploading: 'Uploading…', feedNoQuery: 'Add people or topics to this feed.', feedOfTitle: '{name}’s timeline', feedOfLoading: 'Reading who they follow…', diff --git a/src/media-upload.js b/src/media-upload.js index 686fec5..8e5e8ed 100644 --- a/src/media-upload.js +++ b/src/media-upload.js @@ -2,10 +2,11 @@ import { sha256 } from '@noble/hashes/sha256'; import { bytesToHex } from '@noble/hashes/utils'; // Independent public hosts: a phone upload should not fail just because one -// provider is down or rejects a particular container. nostr.download's NIP-96 -// document currently advertises large image/video/audio uploads; nostr.build -// is the second, widely-used Blossom home. -export const PUBLIC_MEDIA_SERVERS = ['https://nostr.download', 'https://blossom.nostr.build']; +// provider is down or rejects a particular container. nostr.build is the +// widely-used Blossom home and goes first; nostr.download (which advertises +// large image/video/audio uploads) is the fallback — it was unreachable for +// hours on 2026-09-30, and every upload waited on it. +export const PUBLIC_MEDIA_SERVERS = ['https://blossom.nostr.build', 'https://nostr.download']; export class MediaUploadError extends Error { constructor(code, details = '') { @@ -59,7 +60,13 @@ export async function uploadPublicMedia(file, signEvent, { if (!auth) throw new MediaUploadError('failed'); const authorization = 'Nostr ' + btoa(JSON.stringify(auth)); const failures = []; - for (const server of servers) { + for (const [i, server] of servers.entries()) { + // A host that doesn't answer at all costs a few seconds, not the whole + // upload timeout. Any HTTP answer, even an error, means it's there. + if (i < servers.length - 1) { + try { await fetcher(server.replace(/\/$/, '') + '/upload', { method: 'HEAD', signal: AbortSignal.timeout(5000) }); } + catch (error) { failures.push({ status: 0, reason: 'unreachable' }); continue; } + } try { const response = await fetcher(server.replace(/\/$/, '') + '/upload', { method: 'PUT', body: file, signal: AbortSignal.timeout(timeoutMs), diff --git a/src/video-compress.js b/src/video-compress.js new file mode 100644 index 0000000..53d4eb0 --- /dev/null +++ b/src/video-compress.js @@ -0,0 +1,54 @@ +// A phone's video, made light enough to stream before it's uploaded. +// +// A phone records 1080p–4K at 15–20 Mbps; played back from a Blossom host +// over mobile data that stalls every few seconds, and none of the public +// hosts transcode (nostr.build's and primal's BUD-05 /media endpoints hand +// back the same bitrate — measured 2026-09-30). So the phone does it: +// WebCodecs on the device's hardware encoder, via Mediabunny (loaded only +// when a video is picked), to H.264 at 720p and ~2.5 Mbps, the moov atom up +// front so playback starts before the download ends. Audio passes through +// untouched when the container allows (phones record AAC already). +// +// Anything short of a clear win — no WebCodecs, an unreadable file, an +// encoder error, a result not meaningfully smaller — uploads the original, +// exactly as before. + +const SHORT_EDGE = 720; +const VIDEO_BPS = 2_500_000; + +export async function compressVideo(file, onProgress = () => {}) { + if (!file || !/^video\//.test(file.type || '')) return file; + if (typeof VideoEncoder === 'undefined' || typeof VideoDecoder === 'undefined') return file; + let mb; + try { mb = await import('mediabunny'); } catch { return file; } + const { Input, Output, Conversion, BlobSource, BufferTarget, Mp4OutputFormat, ALL_FORMATS } = mb; + const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS }); + try { + const video = await input.getPrimaryVideoTrack(); + if (!video) return file; + const seconds = await input.computeDuration(); + const w = video.displayWidth, h = video.displayHeight; + const short = Math.min(w, h); + const bps = seconds > 0 ? (file.size * 8) / seconds : Infinity; + // already light: nothing to gain, and a re-encode only loses quality + if (short <= SHORT_EDGE && bps <= VIDEO_BPS * 1.4) return file; + const size = short > SHORT_EDGE ? (w >= h ? { height: SHORT_EDGE } : { width: SHORT_EDGE }) : {}; + const output = new Output({ format: new Mp4OutputFormat({ fastStart: 'in-memory' }), target: new BufferTarget() }); + const conversion = await Conversion.init({ + input, output, + video: { ...size, codec: 'avc', bitrate: VIDEO_BPS, forceTranscode: true }, + }); + if (!conversion.isValid) return file; + conversion.onProgress = (p) => { try { onProgress(p); } catch {} }; + await conversion.execute(); + const buf = output.target.buffer; + // not worth it unless it's clearly smaller + if (!buf || buf.byteLength > file.size * 0.8) return file; + const name = String(file.name || 'video').replace(/\.[a-z0-9]{1,8}$/i, '') + '.mp4'; + return new File([buf], name, { type: 'video/mp4' }); + } catch { + return file; + } finally { + try { input.dispose?.(); } catch {} + } +} diff --git a/tools/media-upload-test.js b/tools/media-upload-test.js index f9cc56a..e19cac7 100644 --- a/tools/media-upload-test.js +++ b/tools/media-upload-test.js @@ -10,6 +10,7 @@ let signed = null; const sign = async (event) => (signed = { ...event, id: '1'.repeat(64), pubkey: '2'.repeat(64), sig: '3'.repeat(128) }); const calls = []; const fetcher = async (url, init) => { + if (init?.method === 'HEAD') return new Response('', { status: 200 }); // the reachability probe calls.push({ url, init }); if (url.startsWith('https://first')) return new Response(JSON.stringify({ message: 'temporarily unavailable' }), { status: 503 }); return new Response(JSON.stringify({ url: 'https://cdn.example/clip.mov' }), { status: 201, headers: { 'content-type': 'application/json' } }); @@ -23,6 +24,23 @@ check('hashes the file and binds the hash into Blossom auth', calls[1].init.head && signed.tags.some((tag) => tag[0] === 'x' && tag[1] === calls[1].init.headers['x-sha-256'])); check('sends signed Blossom authorization', calls[1].init.headers.authorization.startsWith('Nostr ')); +// a host that never answers is skipped after the probe, not waited on +{ + const puts = []; + const t0 = Date.now(); + const u = await uploadPublicMedia(file, sign, { + servers: ['https://dead.example', 'https://alive.example'], timeoutMs: 60_000, + fetcher: async (url, init) => { + if (url.startsWith('https://dead')) return new Promise((_, reject) => init.signal.addEventListener('abort', () => reject(new Error('timeout')))); + if (init.method === 'HEAD') return new Response('', { status: 404 }); + puts.push(url); + return new Response(JSON.stringify({ url: 'https://alive.example/x.mov' }), { status: 201 }); + }, + }); + check('an unreachable host is skipped within seconds', u === 'https://alive.example/x.mov' && Date.now() - t0 < 7000 && puts.length === 1); +} +check('nostr.build is the first host', (await import('../src/media-upload.js')).PUBLIC_MEDIA_SERVERS[0] === 'https://blossom.nostr.build'); + const bareHashUrl = await uploadPublicMedia(file, sign, { servers: ['https://media.example'], timeoutMs: 1000, fetcher: async () => new Response(JSON.stringify({ url: '/' + 'a'.repeat(64) }), { status: 201 }), diff --git a/tools/video-compress-test.js b/tools/video-compress-test.js new file mode 100644 index 0000000..dc17036 --- /dev/null +++ b/tools/video-compress-test.js @@ -0,0 +1,101 @@ +// A phone video is shrunk on the device before upload: 1080p at 20 Mbps in, +// H.264 720p around 2.5 Mbps out, moov first (streams while downloading), +// audio kept. Pasted into the reply box like a keyboard/gallery pick; the +// upload is caught and its bytes checked with ffprobe. +// +// Run: bun tools/video-compress-test.js +import puppeteer from 'puppeteer-core'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const SRC = '/tmp/coinos-video-compress-src.mp4', OUT = '/tmp/coinos-video-compress-out.mp4'; +// --portrait: the way phones record upright video — landscape frames plus a +// 90° rotation flag in the container +const PORTRAIT = process.argv.includes('--portrait'); +Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc2=size=1920x1080:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=440', + '-t', '5', '-c:v', 'libx264', '-b:v', '20M', '-maxrate', '20M', '-bufsize', '20M', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-shortest', SRC]); +if (PORTRAIT) { + // (ffmpeg 4 writes the flag only on a remux) + Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-i', SRC, '-c', 'copy', '-metadata:s:v:0', 'rotate=90', SRC + '.rot.mp4']); + Bun.spawnSync(['mv', SRC + '.rot.mp4', SRC]); + const r = JSON.parse(new TextDecoder().decode(Bun.spawnSync(['ffprobe', '-v', 'error', '-show_streams', '-of', 'json', SRC]).stdout)).streams[0]; + console.log(' source rotation:', JSON.stringify(r.side_data_list || r.tags?.rotate || 'none')); +} +const srcSize = Bun.file(SRC).size; +// the split build: the compressor and Mediabunny are chunks loaded on demand +const built = Bun.spawnSync(['bun', 'run', 'build.js'], { stdout: 'pipe', stderr: 'pipe' }); +if (built.exitCode) { console.log(new TextDecoder().decode(built.stderr)); process.exit(1); } +const dist = 'dist'; +const server = Bun.serve({ port: 5287, fetch: async (req) => { + const p = new URL(req.url).pathname; + if (p === '/src.mp4') return new Response(Bun.file(SRC), { headers: { 'content-type': 'video/mp4' } }); + const f = Bun.file(dist + (p === '/' || !p.includes('.') ? '/index.html' : p)); + return (await f.exists()) ? new Response(f) : new Response(Bun.file(dist + '/index.html')); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.evaluateOnNewDocument(() => { + const f = window.fetch; + window.fetch = async (url, init) => { + if (init && init.method === 'PUT' && /nostr\.build|nostr\.download/.test(String(url))) { + window.__put = { size: init.body.size, type: init.body.type, buf: await init.body.arrayBuffer() }; + return new Response(JSON.stringify({ url: 'https://blossom.nostr.build/' + 'f'.repeat(64) + '.mp4' }), { status: 201 }); + } + if (init && init.method === 'HEAD' && /nostr\.build|nostr\.download/.test(String(url))) return new Response('', { status: 200 }); + return f(url, init); + }; +}); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, ms = 15000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5287/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(600); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + await click('skip verification'); + await waitText('receive', 20000); + check('the browser can encode H.264 (WebCodecs)', await page.evaluate(async () => (await VideoEncoder.isConfigSupported({ codec: 'avc1.42001f', width: 1280, height: 720, bitrate: 2.5e6 })).supported)); + // the post composer: the pencil on the feed + await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click()); + await sleep(1500); + await page.evaluate(() => document.querySelector('button[aria-label="New post"], button[title="New post"]')?.click()); + const box = await waitFor(() => !!document.querySelector('.post-input'), 8000); + check('the post composer opens', box); + const t0 = Date.now(); + await page.evaluate(async () => { + const b = await (await fetch('/src.mp4')).blob(); + const dt = new DataTransfer(); + dt.items.add(new File([b], 'phone.mp4', { type: 'video/mp4' })); + document.querySelector('.post-input').dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); + }); + const sawShrink = await waitText('shrinking the video', 15000); + check('the toast says it is shrinking the video', sawShrink); + const done = await waitFor(() => !!window.__put, 120000); + const put = done && await page.evaluate(() => ({ size: window.__put.size, type: window.__put.type })); + const secs = ((Date.now() - t0) / 1000).toFixed(1); + check('the upload is the shrunk video', !!put && put.type === 'video/mp4' && put.size < srcSize * 0.35, + put ? `${(srcSize / 1e6).toFixed(1)} MB → ${(put.size / 1e6).toFixed(1)} MB in ${secs}s` : 'no upload'); + if (put) { + const b64 = await page.evaluate(() => { let s = ''; const u = new Uint8Array(window.__put.buf); for (let i = 0; i < u.length; i += 0x8000) s += String.fromCharCode(...u.subarray(i, i + 0x8000)); return btoa(s); }); + await Bun.write(OUT, Buffer.from(b64, 'base64')); + const probe = JSON.parse(new TextDecoder().decode(Bun.spawnSync(['ffprobe', '-v', 'error', '-show_streams', '-show_format', '-of', 'json', OUT]).stdout)); + const v = probe.streams.find((s) => s.codec_type === 'video'), a = probe.streams.find((s) => s.codec_type === 'audio'); + check('...H.264 at 720p', v?.codec_name === 'h264' && Math.min(v.width, v.height) === 720, `${v?.codec_name} ${v?.width}x${v?.height}`); + if (PORTRAIT) { + // upright on screen: either the frames are portrait, or they keep the rotation flag + const rot = Math.abs(+((v.side_data_list || []).find((d) => d.rotation != null)?.rotation ?? v.tags?.rotate ?? 0)); + check('...still upright (portrait)', (v.height > v.width) !== (rot === 90 || rot === 270), `${v.width}x${v.height} rotation ${rot}`); + } + check('...around 2.5 Mbps', v && +v.bit_rate < 3.5e6, (+v?.bit_rate / 1e6).toFixed(2) + ' Mbps'); + check('...the whole clip, with its sound', Math.abs(+probe.format.duration - 5) < 0.3 && !!a, probe.format.duration + 's, audio ' + a?.codec_name); + const head = new Uint8Array(await Bun.file(OUT).slice(0, 4096).arrayBuffer()); + const txt = new TextDecoder('latin1').decode(head); + check('...moov before mdat, so it plays while downloading', txt.indexOf('moov') >= 0 && (txt.indexOf('mdat') < 0 || txt.indexOf('moov') < txt.indexOf('mdat'))); + } +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ videos are shrunk before upload' : '\n❌ failed'); +process.exit(ok ? 0 : 1); -- cgit v1.2.3 From 0076c591df9432e2bb5b2281afc1fb4106500297 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Wed, 30 Sep 2026 10:23:20 -0700 Subject: A picture or video on its way shows a bar with its percentage — shrinking, then uploading; the pencil reads "New post". MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ui.mediaProgress { stage, pct } (a render per whole percent) drawn as a labelled bar under the reply and post composers, instead of only the paperclip turning into a spinner. Upload progress comes from XMLHttpRequest upload events (fetch() has none) in uploadPublicMedia's new onProgress; injected fetchers (tests) keep the fetch path. upload-progress-test: 1.2 MB at 400 KB/s climbs 0→100 in steps. video-compress-test: the bar counts the shrink, then the upload. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp --- src/app.js | 32 ++++++++++++++++++++------------ src/features/messages.js | 18 ++++++++++++++++-- src/i18n.js | 4 ++-- src/media-upload.js | 26 ++++++++++++++++++++++++-- src/style.css | 5 +++++ tools/upload-progress-test.js | 37 +++++++++++++++++++++++++++++++++++++ tools/video-compress-test.js | 43 ++++++++++++++++++++++++++++++++++--------- 7 files changed, 138 insertions(+), 27 deletions(-) create mode 100644 tools/upload-progress-test.js (limited to 'src/i18n.js') diff --git a/src/app.js b/src/app.js index 5b0bdb6..b6721da 100644 --- a/src/app.js +++ b/src/app.js @@ -3482,24 +3482,32 @@ async function onbUpload(file) { return url; } +// ui.mediaProgress = { stage: 'shrink' | 'upload', pct } while a picture or +// video is on its way; the composers draw it as a bar under the field. +// A render per whole percent, no more. +function mediaProgress(stage, fraction) { + const pct = Math.max(0, Math.min(100, Math.floor(fraction * 100))); + const cur = ui.mediaProgress; + if (cur && cur.stage === stage && cur.pct === pct) return; + ui.mediaProgress = { stage, pct }; + render(); +} async function publicMediaUpload(file) { - // a phone video is shrunk on the device first (see video-compress.js); - // the toast counts it down, since a long clip takes a while - if (/^video\//.test(file?.type || '')) { - let shown = -1; - const { compressVideo } = await import('./video-compress.js'); - file = await compressVideo(file, (p) => { - const pct = Math.floor(p * 100); - if (pct !== shown && pct % 5 === 0) { shown = pct; toast(t('mediaShrinking', { pct }), 60_000); } - }); - if (shown >= 0) toast(t('mediaUploading'), 4000); - } try { - return await uploadPublicMedia(file, (event) => wallet.nostrSign(event)); + // a phone video is shrunk on the device first (see video-compress.js) + if (/^video\//.test(file?.type || '')) { + mediaProgress('shrink', 0); + const { compressVideo } = await import('./video-compress.js'); + file = await compressVideo(file, (p) => mediaProgress('shrink', p)); + } + mediaProgress('upload', 0); + return await uploadPublicMedia(file, (event) => wallet.nostrSign(event), { onProgress: (p) => mediaProgress('upload', p) }); } catch (error) { if (error instanceof MediaUploadError && error.code === 'too-big') throw new Error(t('mediaUploadTooBig')); if (error instanceof MediaUploadError && error.code === 'unsupported') throw new Error(t('mediaUploadUnsupported')); throw new Error(t('msgUploadFailed')); + } finally { + ui.mediaProgress = null; } } diff --git a/src/features/messages.js b/src/features/messages.js index 6a68c38..0c0acf4 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -7127,6 +7127,7 @@ export function messagesFeature(ctx) { style: 'gap:8px;margin:0 -8px;padding:' + (joined ? '2px' : '8px') + ' 8px 10px;background:var(--accent-soft,rgba(128,128,128,.08));border-radius:' + (joined ? '0 0 8px 8px' : '8px'), }, s.preview ? draftPreview(s.draft) : null, + mediaBar(), // A textarea that grows with the reply, like the chat composer: Enter // sends, Shift+Enter (or Ctrl+J) breaks the line — a reply used to be // a one-line field with no way to write a paragraph. @@ -7833,6 +7834,16 @@ export function messagesFeature(ctx) { ? h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...noteBody(body)) : h('div', { class: 'small faint' }, t('composePreviewEmpty'))); } + // Shrinking, then uploading, as a labelled bar with its percentage — the + // paperclip's spinner said only "busy", however long a video took. + function mediaBar() { + const m = ui.mediaProgress; + if (!m) return null; + const label = t(m.stage === 'shrink' ? 'mediaShrinking' : 'mediaUploading'); + return h('div', { class: 'media-progress', role: 'progressbar', 'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': String(m.pct), 'aria-label': label }, + h('div', { class: 'row between small' }, h('span', { class: 'muted' }, label + '…'), h('span', { class: 'media-progress-pct' }, m.pct + '%')), + h('div', { class: 'media-progress-track' }, h('div', { class: 'media-progress-fill', style: 'width:' + m.pct + '%' }))); + } const previewBtn = (on, toggle) => h('button', { class: 'attach-btn' + (on ? ' on' : ''), title: on ? t('composePreviewHide') : t('composePreview'), @@ -7859,6 +7870,7 @@ export function messagesFeature(ctx) { }, }), ui.postPreview ? draftPreview(text) : null, + mediaBar(), h('div', { class: 'row gap6' }, h('button', { class: 'btn-primary grow', disabled: !!ui.postUploading, onClick: async () => { const body = composeText().trim(); @@ -8105,9 +8117,11 @@ export function messagesFeature(ctx) { onClick: () => { ui.feedMenu = true; render(); }, }, '\u22ef'), visitor ? null : h('button', { - class: 'btn-sm', style: 'flex-shrink:0', title: t('profNewPost'), 'aria-label': t('profNewPost'), + // the pencil alone wasn't read as "write a post": it says so + class: 'btn-sm feed-new-post', style: 'flex-shrink:0;display:inline-flex;align-items:center;gap:6px;white-space:nowrap', title: t('profNewPost'), 'aria-label': t('profNewPost'), onClick: () => { ui.profCompose = ui.profCompose == null ? (draftFor(POST_DRAFT) || '') : null; render(); }, - html: '' })), + }, h('span', { style: 'display:flex', html: '' }), + h('span', {}, t('profNewPost')))), ui.feedRelayEdit && ui.feedRelayEdit.id === def.id ? relayPanel(def) : null, visitor ? null : feedChips(), visitor ? null : postComposer(), diff --git a/src/i18n.js b/src/i18n.js index f54e760..9932ced 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -245,8 +245,8 @@ const STR = { feedDelete: 'Delete feed', feedSaveAdhoc: 'Save feed', feedSaved: 'Feed saved', - mediaShrinking: 'Shrinking the video for streaming… {pct}%', - mediaUploading: 'Uploading…', + mediaShrinking: 'Shrinking video for streaming', + mediaUploading: 'Uploading', feedNoQuery: 'Add people or topics to this feed.', feedOfTitle: '{name}’s timeline', feedOfLoading: 'Reading who they follow…', diff --git a/src/media-upload.js b/src/media-upload.js index 8e5e8ed..9301939 100644 --- a/src/media-upload.js +++ b/src/media-upload.js @@ -43,13 +43,32 @@ function mediaUrl(url, server, file) { return out.href; } +// A PUT that reports how much of the body has gone out: fetch() can't, so in +// the browser (with the real fetch) it's XMLHttpRequest. Answers like fetch. +function putWithProgress(url, { body, headers, signal }, onProgress) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('PUT', url); + for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v); + xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded / e.total); }; + xhr.onload = () => resolve(new Response(xhr.responseText, { + status: xhr.status, headers: { 'x-reason': xhr.getResponseHeader('x-reason') || '' } })); + xhr.onerror = () => reject(new Error('network error')); + xhr.ontimeout = () => reject(new Error('timeout')); + if (signal) signal.addEventListener('abort', () => { xhr.abort(); reject(new Error('timeout')); }); + xhr.send(body); + }); +} + // BUD-02 upload. `signEvent` is deliberately injected: the app can use its // always-available wallet identity, while tests need no secret key. export async function uploadPublicMedia(file, signEvent, { servers = PUBLIC_MEDIA_SERVERS, fetcher = globalThis.fetch, timeoutMs = 5 * 60_000, + onProgress = null, // (fraction 0..1) as the bytes go out } = {}) { + const tracked = onProgress && typeof XMLHttpRequest !== 'undefined' && fetcher === globalThis.fetch; if (!file || !file.size || typeof signEvent !== 'function') throw new MediaUploadError('failed'); const x = await fileHash(file); const now = Math.floor(Date.now() / 1000); @@ -68,7 +87,7 @@ export async function uploadPublicMedia(file, signEvent, { catch (error) { failures.push({ status: 0, reason: 'unreachable' }); continue; } } try { - const response = await fetcher(server.replace(/\/$/, '') + '/upload', { + const init = { method: 'PUT', body: file, signal: AbortSignal.timeout(timeoutMs), headers: { authorization, @@ -76,7 +95,10 @@ export async function uploadPublicMedia(file, signEvent, { 'x-sha-256': x, 'x-content-length': String(file.size), }, - }); + }; + if (onProgress) onProgress(0); + const target = server.replace(/\/$/, '') + '/upload'; + const response = tracked ? await putWithProgress(target, init, onProgress) : await fetcher(target, init); let body = null; try { body = await response.json(); } catch {} const url = response.ok && responseUrl(body); diff --git a/src/style.css b/src/style.css index 9bdaf3c..60d850c 100644 --- a/src/style.css +++ b/src/style.css @@ -256,6 +256,11 @@ coinos-text { white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: auto; line-height: 1.4; cursor: text; } coinos-text:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); } +/* a picture or video on its way: shrinking, then uploading (mediaBar) */ +.media-progress { display: flex; flex-direction: column; gap: 4px; } +.media-progress-pct { font-variant-numeric: tabular-nums; } +.media-progress-track { height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; } +.media-progress-fill { height: 100%; background: var(--accent); border-radius: 3px; transition: width .2s linear; } coinos-text[data-empty]::before { content: attr(placeholder); color: var(--muted); pointer-events: none; } .chat-compose coinos-text { flex: 1; min-width: 0; padding: 10px 13px; max-height: 120px; } .mono-input { font-family: var(--mono); } diff --git a/tools/upload-progress-test.js b/tools/upload-progress-test.js new file mode 100644 index 0000000..25bf91c --- /dev/null +++ b/tools/upload-progress-test.js @@ -0,0 +1,37 @@ +// uploadPublicMedia reports how much of the file has gone out (XMLHttpRequest +// upload events — fetch() can't), from 0 to 1, on a slow uplink. +// +// Run: bun tools/upload-progress-test.js +import puppeteer from 'puppeteer-core'; +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const js = await Bun.build({ entrypoints: ['src/media-upload.js'], target: 'browser' }).then((b) => b.outputs[0].text()); +let got = 0; +const server = Bun.serve({ port: 5289, async fetch(req) { + const cors = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'PUT, HEAD', 'access-control-allow-headers': '*', 'access-control-expose-headers': 'x-reason' }; + const p = new URL(req.url).pathname; + if (req.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors }); + if (p === '/upload' && req.method === 'PUT') { got = (await req.arrayBuffer()).byteLength; return Response.json({ url: 'http://localhost:5289/' + 'a'.repeat(64) }, { status: 201, headers: cors }); } + if (p === '/m.js') return new Response(js, { headers: { 'content-type': 'text/javascript' } }); + return new Response('', { headers: { 'content-type': 'text/html' } }); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +try { + await page.goto('http://localhost:5289/'); + await page.waitForFunction(() => !!window.m); + const cdp = await page.createCDPSession(); + await cdp.send('Network.emulateNetworkConditions', { offline: false, latency: 20, downloadThroughput: 5e6, uploadThroughput: 400e3 }); + const r = await page.evaluate(async () => { + const seen = []; + const file = new File([new Uint8Array(1_200_000)], 'v.mp4', { type: 'video/mp4' }); + const sign = async (e) => ({ ...e, id: '1'.repeat(64), pubkey: '2'.repeat(64), sig: '3'.repeat(128) }); + const url = await window.m.uploadPublicMedia(file, sign, { servers: ['http://localhost:5289'], onProgress: (p) => seen.push(Math.round(p * 100)) }); + return { url, seen }; + }); + check('the upload lands', r.url.startsWith('http://localhost:5289/') && got === 1_200_000, got + ' bytes'); + check('progress climbs from 0 to 100 as the bytes go', r.seen[0] === 0 && r.seen.at(-1) === 100 && r.seen.filter((p) => p > 0 && p < 100).length >= 3 + && r.seen.every((p, i) => !i || p >= r.seen[i - 1]), JSON.stringify(r.seen.slice(0, 15)) + (r.seen.length > 15 ? '…' : '')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ uploads report their progress' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/video-compress-test.js b/tools/video-compress-test.js index dc17036..ce1a936 100644 --- a/tools/video-compress-test.js +++ b/tools/video-compress-test.js @@ -34,16 +34,34 @@ const server = Bun.serve({ port: 5287, fetch: async (req) => { } }); const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); const page = await browser.newPage(); +// the upload goes out by XMLHttpRequest (for its progress events): keep a +// copy of the body, and answer at the network layer — nothing leaves await page.evaluateOnNewDocument(() => { - const f = window.fetch; - window.fetch = async (url, init) => { - if (init && init.method === 'PUT' && /nostr\.build|nostr\.download/.test(String(url))) { - window.__put = { size: init.body.size, type: init.body.type, buf: await init.body.arrayBuffer() }; - return new Response(JSON.stringify({ url: 'https://blossom.nostr.build/' + 'f'.repeat(64) + '.mp4' }), { status: 201 }); + const open = XMLHttpRequest.prototype.open, send = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function (m, url) { this.__m = m; this.__url = String(url); return open.apply(this, arguments); }; + XMLHttpRequest.prototype.send = function (body) { + if (this.__m === 'PUT' && /nostr\.build|nostr\.download/.test(this.__url) && body instanceof Blob) { + body.arrayBuffer().then((buf) => { window.__put = { size: body.size, type: body.type, buf }; }); } - if (init && init.method === 'HEAD' && /nostr\.build|nostr\.download/.test(String(url))) return new Response('', { status: 200 }); - return f(url, init); + return send.apply(this, arguments); }; + // every state the progress bar shows + window.__prog = []; + new MutationObserver(() => { + const m = document.querySelector('.media-progress'); + const s = m ? m.innerText.replace(/\s+/g, ' ').trim() : null; + if (s && window.__prog.at(-1) !== s) window.__prog.push(s); + }).observe(document, { childList: true, subtree: true, characterData: true, attributes: true }); +}); +await page.setRequestInterception(true); +page.on('request', (req) => { + const u = req.url(); + if (!/nostr\.build|nostr\.download/.test(u)) return req.continue(); + const cors = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'PUT, HEAD, GET', 'access-control-allow-headers': '*' }; + if (req.method() === 'OPTIONS') return req.respond({ status: 204, headers: cors }); + if (req.method() === 'HEAD') return req.respond({ status: 200, headers: cors }); + return req.respond({ status: 201, headers: cors, contentType: 'application/json', + body: JSON.stringify({ url: 'https://blossom.nostr.build/' + 'f'.repeat(64) + '.mp4' }) }); }); const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; @@ -72,9 +90,16 @@ try { dt.items.add(new File([b], 'phone.mp4', { type: 'video/mp4' })); document.querySelector('.post-input').dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); }); - const sawShrink = await waitText('shrinking the video', 15000); - check('the toast says it is shrinking the video', sawShrink); const done = await waitFor(() => !!window.__put, 120000); + await waitFor(() => !document.querySelector('.media-progress'), 10000); + const prog = await page.evaluate(() => window.__prog); + const pcts = (stage) => prog.filter((x) => x.startsWith(stage)).map((x) => +x.match(/(\d+)%/)[1]); + const shrink = pcts('Shrinking video'), up = pcts('Uploading'); + check('a bar shows the shrinking with its percentage', shrink.length >= 3 && shrink.some((p) => p > 0 && p < 100), JSON.stringify(shrink.slice(0, 12))); + // (the answer here is instant, so only its start shows — the percentages + // themselves are upload-progress-test's) + check('...then the upload with its own', up.length >= 1 && prog.findIndex((x) => x.startsWith('Uploading')) > prog.findIndex((x) => x.startsWith('Shrinking')), JSON.stringify(up)); + check('...and goes away when it is done', await page.evaluate(() => !document.querySelector('.media-progress'))); const put = done && await page.evaluate(() => ({ size: window.__put.size, type: window.__put.type })); const secs = ((Date.now() - t0) / 1000).toFixed(1); check('the upload is the shrunk video', !!put && put.type === 'video/mp4' && put.size < srcSize * 0.35, -- cgit v1.2.3 From cfeacb1a7a10766565ed3eaab13154096478c837 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Wed, 30 Sep 2026 10:25:06 -0700 Subject: "Post" floats at the feed's bottom right instead of sitting in the header. A pill with the pencil and "Post", fixed above the bottom nav inside the page column, wherever the reader is in the feed; the tap scrolls to the top, opens the composer and focuses it. Hidden while the composer is open (it has its own Post button) and for visitors. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp --- src/features/messages.js | 18 ++++++++++++------ src/i18n.js | 1 + src/style.css | 10 ++++++++++ 3 files changed, 23 insertions(+), 6 deletions(-) (limited to 'src/i18n.js') diff --git a/src/features/messages.js b/src/features/messages.js index 0c0acf4..a52efec 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -8116,14 +8116,20 @@ export function messagesFeature(ctx) { class: 'btn-sm' + (feedRelays(def) ? ' on' : ''), title: t('feedMenu'), 'aria-label': t('feedMenu'), style: 'margin-left:auto;flex-shrink:0', onClick: () => { ui.feedMenu = true; render(); }, }, '\u22ef'), - visitor ? null : h('button', { - // the pencil alone wasn't read as "write a post": it says so - class: 'btn-sm feed-new-post', style: 'flex-shrink:0;display:inline-flex;align-items:center;gap:6px;white-space:nowrap', title: t('profNewPost'), 'aria-label': t('profNewPost'), - onClick: () => { ui.profCompose = ui.profCompose == null ? (draftFor(POST_DRAFT) || '') : null; render(); }, - }, h('span', { style: 'display:flex', html: '' }), - h('span', {}, t('profNewPost')))), + ), ui.feedRelayEdit && ui.feedRelayEdit.id === def.id ? relayPanel(def) : null, visitor ? null : feedChips(), + // 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', { style: 'display:flex', html: '' }), 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' }, diff --git a/src/i18n.js b/src/i18n.js index 9932ced..02584b2 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -245,6 +245,7 @@ const STR = { feedDelete: 'Delete feed', feedSaveAdhoc: 'Save feed', feedSaved: 'Feed saved', + feedPostFab: 'Post', mediaShrinking: 'Shrinking video for streaming', mediaUploading: 'Uploading', feedNoQuery: 'Add people or topics to this feed.', diff --git a/src/style.css b/src/style.css index 60d850c..86d5d45 100644 --- a/src/style.css +++ b/src/style.css @@ -827,6 +827,16 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } content: ''; position: absolute; top: -2px; right: -4px; width: 7px; height: 7px; border-radius: 50%; background: var(--green); border: 1px solid var(--paper); } +.feed-post-fab { + /* the "write a post" button floats over the feed's bottom right, above + the nav, inside the page column */ + position: fixed; z-index: 25; + bottom: calc(env(safe-area-inset-bottom, 0px) + 82px); + right: max(16px, calc((100% - 560px) / 2 + 16px)); + display: inline-flex; align-items: center; gap: 7px; width: auto; + padding: 12px 18px; border-radius: 999px; font-weight: 600; + box-shadow: 0 4px 14px rgba(0, 0, 0, .22); +} .feed-page .feed-new-pill { bottom: calc(env(safe-area-inset-bottom, 0px) + 82px); } -- cgit v1.2.3