From 5989090d93119fd10c642db622986227e4ae1b03 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 28 Sep 2026 20:15:06 -0700 Subject: Enlarge post text and use full width below author headers --- src/features/messages.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index fb2986f..31a3e4d 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -6403,10 +6403,10 @@ export function messagesFeature(ctx) { const openable = open && !pending; if (!pending) watchZaps([ev.id]); return h('div', { - class: 'row', + class: 'row note-post', 'data-zap-post': ev.id, 'data-focus-note': focus ? '1' : undefined, - style: 'gap:10px;align-items:flex-start;padding:10px 0' + style: 'padding:10px 0' + (openable ? ';cursor:pointer' : '') + (pending ? ';opacity:.55' : '') // joined: the reply box continues this highlight below, so the @@ -6419,8 +6419,8 @@ export function messagesFeature(ctx) { // the avatar is its own tap-target (profile), even inside an openable // row — its handler stops propagation, so the row still opens the thread avatar(pk, 'chat-avatar note-avatar'), - h('div', { class: 'col grow', style: 'min-width:0;gap:3px' }, - h('div', { class: 'row between', style: 'align-items:center;gap:8px' }, + h('div', { class: 'note-content' }, + h('div', { class: 'row between note-header', style: 'align-items:center;gap:8px' }, h('div', { class: 'row', style: 'gap:7px;align-items:baseline;min-width:0' }, h('span', { // the name, like the avatar, is its own tap-target (profile) -- cgit v1.2.3 From f95093a90637abfa9f0adbb3d7d28127ecd20569 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 28 Sep 2026 20:22:06 -0700 Subject: Add scrolling feed navigation for home, messages and notifications --- src/features/messages.js | 27 +++++++++++++++- src/style.css | 18 +++++++++++ tools/feed-bottom-nav-test.js | 73 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 tools/feed-bottom-nav-test.js (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index 31a3e4d..3af968f 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -3472,6 +3472,9 @@ export function messagesFeature(ctx) { // only runs once a wallet opens. if (typeof window !== 'undefined') { window.addEventListener('scroll', () => { + // Update just the navigation visibility; scrolling must not repaint posts. + const nav = document.querySelector('.feed-bottom-nav'); + if (nav) nav.hidden = window.scrollY < 160; if (!ui.profilePk && (!(ui.chatOpen && ui.msgView === 'feed') || ui.noteThread)) return; windowScrolled(ui.profilePk ? notesCache.get(ui.profilePk) : feed); // back at the top by yourself: the new posts are under your eyes, so @@ -7524,6 +7527,27 @@ export function messagesFeature(ctx) { } catch {} }, 0); } + function feedBottomNav() { + const icon = (paths) => ''; + const button = (label, paths, onClick) => h('button', { + type: 'button', class: 'feed-nav-button', 'aria-label': label, onClick, + }, h('span', { html: icon(paths) }), h('span', {}, label)); + return h('nav', { + class: 'feed-bottom-nav', 'data-key': 'feed-bottom-nav', 'aria-label': t('feedTitle'), + hidden: typeof window === 'undefined' || window.scrollY < 160, + }, + button(t('home'), '', jumpToNew), + button(t('msgDmsTitle'), '', () => { + if (signinAsk()) return; + stopFeedWatch(); ui.msgView = 'home'; render(); + window.scrollTo({ top: 0 }); + }), + button(t('alertsTitle'), '', () => { + if (signinAsk()) return; + stopFeedWatch(); openNotifs(); + window.scrollTo({ top: 0 }); + })); + } function feedView() { syncFollowSets().catch(() => {}); // throttled inside syncReports().catch(() => {}); // likewise @@ -7563,7 +7587,7 @@ export function messagesFeature(ctx) { waiting === 1 ? t('feedOneNewWord') : t('feedNNewWord')) : null; // the chat shell draws the brand header; this is just the page under it - return h('div', { class: 'card col chat-page', style: 'gap:10px' }, + return h('div', { class: 'card col chat-page feed-page', style: 'gap:10px' }, h('div', { 'data-key': 'feed-notice', style: 'display:contents' }, pill), h('div', { class: 'row gap6', style: 'align-items:center' }, backBtn(() => { @@ -7618,6 +7642,7 @@ export function messagesFeature(ctx) { // the reader's language, right where a curated feed is judged by it ctx.languagePicker ? h('div', { class: 'row gap6 feed-lang', 'data-key': 'feed-lang', style: 'justify-content:center;align-items:center;padding:6px 0 2px' }, h('span', { class: 'small muted' }, t('feedLangLabel')), ctx.languagePicker()) : null, + feedBottomNav(), ...noteOverlays()); } diff --git a/src/style.css b/src/style.css index 6507ba5..d2ad443 100644 --- a/src/style.css +++ b/src/style.css @@ -770,6 +770,24 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } 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 sticky element inside the card would scroll away with the card. */ +.feed-page { padding-bottom: calc(82px + env(safe-area-inset-bottom, 0px)); } +.feed-bottom-nav { + position: fixed; bottom: 0; left: 50%; transform: translateX(-50%); + z-index: 30; display: flex; width: min(100%, 560px); box-sizing: border-box; + padding: 6px 12px calc(6px + env(safe-area-inset-bottom, 0px)); + background: var(--paper); border-top: 1px solid var(--line); + box-shadow: 0 -4px 18px rgba(0, 0, 0, .06); +} +.feed-bottom-nav[hidden] { display: none; } +.feed-nav-button { + display: flex; flex: 1; min-width: 0; flex-direction: column; align-items: center; + justify-content: center; gap: 3px; min-height: 52px; padding: 4px; + border: 0; background: transparent; color: var(--ink); font-size: 12px; +} +.feed-nav-button > span:first-child { display: flex; } +.feed-page:has(.feed-bottom-nav:not([hidden])) .feed-new-pill { + bottom: calc(env(safe-area-inset-bottom, 0px) + 82px); +} .feed-new-pill { /* Rides at the bottom of the window, where a thumb already is; the tap takes the reader up to the new posts. */ diff --git a/tools/feed-bottom-nav-test.js b/tools/feed-bottom-nav-test.js new file mode 100644 index 0000000..f277c4a --- /dev/null +++ b/tools/feed-bottom-nav-test.js @@ -0,0 +1,73 @@ +// Real feed/navigation views with local posts and no relay traffic. +// Run: bun tools/feed-bottom-nav-test.js +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer-core'; +const app = await Bun.file('src/app.js').text(); +const dom = app.slice(app.indexOf('function h(tag,'), app.indexOf("const root = document.getElementById('app');")); +const entry = ` +import { messagesFeature } from './src/features/messages.js'; +${dom} +const author = 'a'.repeat(64); +const ui = { screen: 'wallet', chatOpen: true, msgView: 'feed' }; +const wallet = { xpub: 'test-wallet', loaded: true, nostrRelays: () => [], + loadFeatureState: (k, d) => k === 'follows' ? { tags: [['p', author]], at: Date.now()/1000 } : d, + saveFeatureState() {}, registerCacheExtension() {} }; +let feature; +function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView()]); } +feature = messagesFeature({ h, ui, wallet, render, toast() {}, hook() {}, brandHeader: () => h('div', { style: 'height:60px' }, 'Coinos') }); +window.test = { ui, wallet, render, async feed() { + ui.chatOpen = true; ui.msgView = 'feed'; ui.noteThread = null; + await feature.testSeed(Array.from({length:20}, (_, i) => ({ id: i.toString(16).padStart(64,'0'), pubkey:author, kind:1, tags:[], created_at:Math.floor(Date.now()/1000)-i, + content: 'A post in the feed. Scroll to keep Home, messages and notifications within reach. '.repeat(3) }))); + render(); window.scrollTo(0,0); +}, unseen() { feature.testUnseen(); render(); } }; +await test.feed(); +`; +const bundle = await Bun.build({ entrypoints: ['nav-test-entry'], target: 'browser', plugins: [{ name: 'nav-test', setup(build) { + build.onResolve({filter:/^nav-test-entry$/},()=>({path:'entry',namespace:'nav-test'})); + build.onLoad({filter:/.*/,namespace:'nav-test'},()=>({contents:entry,loader:'js',resolveDir:process.cwd()})); + build.onLoad({filter:/src\/features\/messages\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()).replace("id: 'messages',", "id: 'messages', testSeed: async (notes) => { const c = feedNow(); await c.boot; Object.assign(c, { notes, status: 'ready', booting: false, end: true, shown: 20 }); }, testUnseen: () => { feed.unseen = 2; },")})); + build.onLoad({filter:/src\/nostr\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()) + .replace('export async function queryOn(relays, filter, maxWait = 1500) {','export async function queryOn(relays, filter, maxWait = 1500) { return [];') + .replace(/export function subscribeOn\(([^)]*)\) \{/,'export function subscribeOn($1) { return () => {};')})); +} }] }); +assert(bundle.success,bundle.logs.join('\n')); +const html=`
`; +const remote = !!process.env.BROWSER_URL; +const server=Bun.serve({port:remote ? 5303 : 0,fetch:()=>new Response(html,{headers:{'content-type':'text/html'}})}); +const browser=remote ? await puppeteer.connect({browserURL:process.env.BROWSER_URL}) : await puppeteer.launch({executablePath:'/usr/bin/google-chrome',headless:true,args:['--no-sandbox']}); +const page=await browser.newPage(), errors=[]; +page.on('pageerror', e=>errors.push(e.message)); +await page.setRequestInterception(true); +page.on('request',r=>r.url().startsWith(server.url.origin)?r.continue():r.abort()); +const scroll=async()=>{ await page.evaluate(()=>window.scrollTo(0,700)); await page.waitForFunction(()=>!document.querySelector('.feed-bottom-nav')?.hidden); }; +try { + await page.setViewport({width:390,height:844,isMobile:true,hasTouch:true}); + await page.emulateMediaFeatures([{name:'prefers-reduced-motion',value:'reduce'}]); + await page.goto(server.url.href); + await page.waitForSelector('.note-post', {timeout:5000}); + assert(await page.$eval('.feed-bottom-nav',e=>e.hidden),'hidden at top'); + await scroll(); + assert.deepEqual(await page.$$eval('.feed-nav-button',els=>els.map(e=>e.textContent)),['Home','Messages','Notifications']); + assert(await page.$eval('.feed-bottom-nav',e=>Math.abs(e.getBoundingClientRect().bottom-innerHeight)<1),'fixed at viewport bottom'); + await page.evaluate(()=>test.unseen()); + assert(await page.evaluate(()=>document.querySelector('.feed-new-pill').getBoundingClientRect().bottom < document.querySelector('.feed-bottom-nav').getBoundingClientRect().top),'new posts pill stays above navigation'); + await page.screenshot({path:'/tmp/feed-bottom-nav-mobile.png'}); + await page.tap('.feed-nav-button:nth-child(1)'); + await page.waitForFunction(()=>scrollY===0 && document.querySelector('.feed-bottom-nav').hidden); + assert.equal(await page.evaluate(()=>test.ui.msgView),'feed','Home stays in feed'); + await scroll(); await page.tap('.feed-nav-button:nth-child(2)'); + await page.waitForFunction(()=>test.ui.msgView==='home' && !document.querySelector('.feed-bottom-nav')); + assert((await page.evaluate(()=>document.body.innerText)).includes('Messages'),'chat list renders'); + await page.evaluate(()=>test.feed()); await scroll(); await page.tap('.feed-nav-button:nth-child(3)'); + await page.waitForFunction(()=>test.ui.msgView==='notifs' && !document.querySelector('.feed-bottom-nav')); + assert((await page.evaluate(()=>document.body.innerText)).includes('Notifications'),'notifications render'); + await page.evaluate(()=>test.feed()); await scroll(); await page.tap('.note-text'); + await page.waitForFunction(()=>!!test.ui.noteThread && !document.querySelector('.feed-bottom-nav')); + await page.evaluate(()=>test.feed()); + await page.evaluate(()=>{test.wallet.xpub=null;test.render();}); await scroll(); + await page.tap('.feed-nav-button:nth-child(2)'); + assert(await page.evaluate(()=>test.ui.signinAsk && test.ui.msgView==='feed'),'visitor gets sign-in prompt'); + assert.deepEqual(errors,[]); + console.log('✓ Feed nav appears on scroll; Home returns to top; Messages and Notifications open their views; nav leaves threads; visitor sign-in and new-post pill work'); +} finally { await page.close(); if (remote) await browser.disconnect(); else await browser.close(); server.stop(true); } -- cgit v1.2.3 From 59e6028b6e62820fcde93068347c44637b67c93d Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 28 Sep 2026 21:03:31 -0700 Subject: Keep feed navigation visible and add a wallet shortcut --- src/features/messages.js | 10 ++++++---- src/style.css | 7 +++---- tools/feed-bottom-nav-test.js | 19 ++++++++++++++----- 3 files changed, 23 insertions(+), 13 deletions(-) (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index 3af968f..0b40a5a 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -3472,9 +3472,6 @@ export function messagesFeature(ctx) { // only runs once a wallet opens. if (typeof window !== 'undefined') { window.addEventListener('scroll', () => { - // Update just the navigation visibility; scrolling must not repaint posts. - const nav = document.querySelector('.feed-bottom-nav'); - if (nav) nav.hidden = window.scrollY < 160; if (!ui.profilePk && (!(ui.chatOpen && ui.msgView === 'feed') || ui.noteThread)) return; windowScrolled(ui.profilePk ? notesCache.get(ui.profilePk) : feed); // back at the top by yourself: the new posts are under your eyes, so @@ -7534,7 +7531,6 @@ export function messagesFeature(ctx) { }, h('span', { html: icon(paths) }), h('span', {}, label)); return h('nav', { class: 'feed-bottom-nav', 'data-key': 'feed-bottom-nav', 'aria-label': t('feedTitle'), - hidden: typeof window === 'undefined' || window.scrollY < 160, }, button(t('home'), '', jumpToNew), button(t('msgDmsTitle'), '', () => { @@ -7546,6 +7542,12 @@ export function messagesFeature(ctx) { if (signinAsk()) return; stopFeedWatch(); openNotifs(); window.scrollTo({ top: 0 }); + }), + button(t('settingsWallet'), '', () => { + if (isVisitor()) { toFrontDoor(); return; } + stopFeedWatch(); + ui.chatOpen = false; ui.msgView = null; ui.tab = 'receive'; + render(); window.scrollTo({ top: 0 }); })); } function feedView() { diff --git a/src/style.css b/src/style.css index d2ad443..3a8c036 100644 --- a/src/style.css +++ b/src/style.css @@ -774,18 +774,17 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } .feed-bottom-nav { position: fixed; bottom: 0; left: 50%; transform: translateX(-50%); z-index: 30; display: flex; width: min(100%, 560px); box-sizing: border-box; - padding: 6px 12px calc(6px + env(safe-area-inset-bottom, 0px)); + padding: 6px 4px calc(6px + env(safe-area-inset-bottom, 0px)); background: var(--paper); border-top: 1px solid var(--line); box-shadow: 0 -4px 18px rgba(0, 0, 0, .06); } -.feed-bottom-nav[hidden] { display: none; } .feed-nav-button { display: flex; flex: 1; min-width: 0; flex-direction: column; align-items: center; justify-content: center; gap: 3px; min-height: 52px; padding: 4px; - border: 0; background: transparent; color: var(--ink); font-size: 12px; + border: 0; background: transparent; color: var(--ink); font-size: 11px; } .feed-nav-button > span:first-child { display: flex; } -.feed-page:has(.feed-bottom-nav:not([hidden])) .feed-new-pill { +.feed-page .feed-new-pill { bottom: calc(env(safe-area-inset-bottom, 0px) + 82px); } .feed-new-pill { diff --git a/tools/feed-bottom-nav-test.js b/tools/feed-bottom-nav-test.js index f277c4a..53b7529 100644 --- a/tools/feed-bottom-nav-test.js +++ b/tools/feed-bottom-nav-test.js @@ -13,7 +13,7 @@ const wallet = { xpub: 'test-wallet', loaded: true, nostrRelays: () => [], loadFeatureState: (k, d) => k === 'follows' ? { tags: [['p', author]], at: Date.now()/1000 } : d, saveFeatureState() {}, registerCacheExtension() {} }; let feature; -function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView()]); } +function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView() || h('div', { class: 'wallet-screen' }, 'Wallet')]); } feature = messagesFeature({ h, ui, wallet, render, toast() {}, hook() {}, brandHeader: () => h('div', { style: 'height:60px' }, 'Coinos') }); window.test = { ui, wallet, render, async feed() { ui.chatOpen = true; ui.msgView = 'feed'; ui.noteThread = null; @@ -46,15 +46,15 @@ try { await page.emulateMediaFeatures([{name:'prefers-reduced-motion',value:'reduce'}]); await page.goto(server.url.href); await page.waitForSelector('.note-post', {timeout:5000}); - assert(await page.$eval('.feed-bottom-nav',e=>e.hidden),'hidden at top'); + assert(await page.$eval('.feed-bottom-nav',e=>!e.hidden && e.getBoundingClientRect().height>0),'visible at top without scrolling'); await scroll(); - assert.deepEqual(await page.$$eval('.feed-nav-button',els=>els.map(e=>e.textContent)),['Home','Messages','Notifications']); + assert.deepEqual(await page.$$eval('.feed-nav-button',els=>els.map(e=>e.textContent)),['Home','Messages','Notifications','Wallet']); assert(await page.$eval('.feed-bottom-nav',e=>Math.abs(e.getBoundingClientRect().bottom-innerHeight)<1),'fixed at viewport bottom'); await page.evaluate(()=>test.unseen()); assert(await page.evaluate(()=>document.querySelector('.feed-new-pill').getBoundingClientRect().bottom < document.querySelector('.feed-bottom-nav').getBoundingClientRect().top),'new posts pill stays above navigation'); await page.screenshot({path:'/tmp/feed-bottom-nav-mobile.png'}); await page.tap('.feed-nav-button:nth-child(1)'); - await page.waitForFunction(()=>scrollY===0 && document.querySelector('.feed-bottom-nav').hidden); + await page.waitForFunction(()=>scrollY===0 && !document.querySelector('.feed-bottom-nav').hidden); assert.equal(await page.evaluate(()=>test.ui.msgView),'feed','Home stays in feed'); await scroll(); await page.tap('.feed-nav-button:nth-child(2)'); await page.waitForFunction(()=>test.ui.msgView==='home' && !document.querySelector('.feed-bottom-nav')); @@ -65,9 +65,18 @@ try { await page.evaluate(()=>test.feed()); await scroll(); await page.tap('.note-text'); await page.waitForFunction(()=>!!test.ui.noteThread && !document.querySelector('.feed-bottom-nav')); await page.evaluate(()=>test.feed()); + assert(await page.$eval('.feed-bottom-nav',e=>!e.hidden && e.getBoundingClientRect().height>0),'returning to feed restores nav immediately without a scroll'); + await page.evaluate(()=>{test.ui.tab='settings';}); + await page.tap('.feed-nav-button:nth-child(4)'); + await page.waitForSelector('.wallet-screen'); + assert(await page.evaluate(()=>!test.ui.chatOpen && test.ui.tab==='receive' && !document.querySelector('.feed-bottom-nav')),'Wallet opens main wallet instead of old settings tab'); + await page.evaluate(()=>test.feed()); + await page.setViewport({width:320,height:844,isMobile:true,hasTouch:true}); + assert(await page.$eval('.feed-bottom-nav',e=>e.scrollWidth<=e.clientWidth),'four buttons fit a narrow phone'); + await page.screenshot({path:'/tmp/feed-bottom-nav-always-mobile.png'}); await page.evaluate(()=>{test.wallet.xpub=null;test.render();}); await scroll(); await page.tap('.feed-nav-button:nth-child(2)'); assert(await page.evaluate(()=>test.ui.signinAsk && test.ui.msgView==='feed'),'visitor gets sign-in prompt'); assert.deepEqual(errors,[]); - console.log('✓ Feed nav appears on scroll; Home returns to top; Messages and Notifications open their views; nav leaves threads; visitor sign-in and new-post pill work'); + console.log('✓ Feed nav stays visible at top and on return; Home, Messages, Notifications and Wallet work; four buttons fit a narrow phone; threads, visitor sign-in and new-post pill work'); } finally { await page.close(); if (remote) await browser.disconnect(); else await browser.close(); server.stop(true); } -- cgit v1.2.3 From 292d5527cf36c119d74486061db3a44a608f5138 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 28 Sep 2026 21:27:32 -0700 Subject: The bottom nav rides every page: Feed, Messages, Notifications, Wallet. It now shows across the signed-in app and a visitor's public pages (never over sign-in, onboarding or the lock prompt), marks the current page, and carries the unread dot. Home is renamed Feed, and the Messages button leaves the header. Co-Authored-By: Claude Opus 5.5 --- src/app.js | 24 ++++------------ src/features/messages.js | 47 ++++++++++++++++++++++---------- src/style.css | 13 ++++++--- tools/feed-bottom-nav-test.js | 62 +++++++++++++++++++++++------------------- tools/feed-wallet-back-test.js | 11 ++++---- 5 files changed, 87 insertions(+), 70 deletions(-) (limited to 'src/features') diff --git a/src/app.js b/src/app.js index 7dde883..5e3fba4 100644 --- a/src/app.js +++ b/src/app.js @@ -681,7 +681,11 @@ function renderInner() { ui.navAnimSkip = false; } applyAnim(screen, 'anim-page', (performance.now() - _navAt) < 340 ? performance.now() - _navAt : -1); - morphChildren(root, [screen, footer(), ...(ui.lightbox ? [imageViewer()] : [])]); + // The bottom nav rides every page of a signed-in wallet and a visitor's + // public pages — never over sign-in, onboarding or the lock prompt. + const navPage = ui.pubProf || (activeAccount() && ['wallet', 'accounts', 'accountSettings'].includes(ui.screen) && !(ui.onb || onbInProgress())); + const bottomNav = navPage && !lockAsk && !_bootDeciding ? featureHook('bottomNav') : null; + morphChildren(root, [screen, footer(), ...(bottomNav ? [bottomNav] : []), ...(ui.lightbox ? [imageViewer()] : [])]); promoteLazySrc(root); // sources on the nodes that actually made it into the page try { document.documentElement.classList.toggle('no-scroll', !!ui.lightbox); } catch {} if (fpath) { @@ -2488,23 +2492,6 @@ function lockBtn() { }, h('span', { style: 'font-size:15px;line-height:1;margin-right:6px' }, locked ? '\u{1F512}' : '\u{1F513}'), locked ? t('unlock') : t('lockWallet')); } -// Messages sit one tap away, left of the wallet selector. The dot is presence, -// not a count — it says "someone's waiting", and the chat list says who. -function messagesBtn() { - const me = (featureHook('nostrLoginIdentity') || {}).pubkey || (wallet.nostrPubkey && wallet.nostrPubkey()); - if (!me) return null; - const unread = featureHook('unreadMessages') || 0; - return h('button', { - class: 'header-msgs' + (unread ? ' unread' : ''), - title: t('msgDmsTitle'), - 'aria-label': t('msgDmsTitle'), - onClick: () => { clearFeatureNav(); ui.screen = 'wallet'; ui.chatOpen = true; ui.msgView = 'home'; render(); }, - }, h('span', { - class: 'hm-ico', - html: '', - })); -} - // Find anyone on nostr from the header — results open their profile. function searchBtn() { if (!featureHook('userSearchAvailable')) return null; @@ -2539,7 +2526,6 @@ function brandHeader(withLock) { withLock ? h('div', { class: 'row gap6', style: 'align-items:center' }, searchBtn(), - messagesBtn(), settingsBtn(), avatarMenu()) : ui.pubProf && !acc ? visitorButtons() : null diff --git a/src/features/messages.js b/src/features/messages.js index 0b40a5a..7be5f26 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -7524,28 +7524,47 @@ export function messagesFeature(ctx) { } catch {} }, 0); } - function feedBottomNav() { + function appBottomNav() { + const visitor = isVisitor(); + const active = ui.noteThread || ui.profilePk ? 'feed' : ui.chatOpen + ? ui.msgView === 'feed' ? 'feed' : ui.msgView === 'notifs' ? 'notifs' : 'messages' : 'wallet'; + const leavePage = () => { + stopFeedWatch(); stopNotifWatch(); + // Top-level destinations must leave any feature page covering the shell. + for (const key of ['profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'noteThread', 'userSearch', + 'zapSetup', 'hatShop', 'feedEdit', 'feedMenu', 'feedRelayEdit', 'noteSheet', 'reactPick', 'reportSheet', + 'msgReplyTo', 'msgSheet', 'arkCoinsPage', 'arkExitPage', 'nameEditOpen', 'nostrReconnect', 'nostrLoginOpen', + 'pos', 'lockedGift', 'viewGift', 'claimChoose', 'signinAsk']) ui[key] = null; + if (!visitor) { ui.screen = 'wallet'; ui.pubProf = null; } + }; const icon = (paths) => ''; - const button = (label, paths, onClick) => h('button', { - type: 'button', class: 'feed-nav-button', 'aria-label': label, onClick, + const button = (id, label, paths, onClick) => h('button', { + type: 'button', class: 'app-nav-button' + (id === 'messages' && !visitor && hook('unreadMessages') ? ' unread' : ''), + 'aria-label': label, 'aria-current': active === id ? 'page' : undefined, onClick, }, h('span', { html: icon(paths) }), h('span', {}, label)); return h('nav', { - class: 'feed-bottom-nav', 'data-key': 'feed-bottom-nav', 'aria-label': t('feedTitle'), + class: 'app-bottom-nav', 'data-key': 'app-bottom-nav', }, - button(t('home'), '', jumpToNew), - button(t('msgDmsTitle'), '', () => { - if (signinAsk()) return; - stopFeedWatch(); ui.msgView = 'home'; render(); + button('feed', t('feedTitle'), '', () => { + if (active === 'feed' && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread && !ui.feedEdit) { jumpToNew(); return; } + leavePage(); + if (visitor) { ui.pubProf = true; openFirehose(); } else showFeed(curFeedId); + window.scrollTo({ top: 0 }); + }), + button('messages', t('msgDmsTitle'), '', () => { + if (visitor) { toFrontDoor(); return; } + leavePage(); ui.chatOpen = true; ui.msgView = 'home'; render(); window.scrollTo({ top: 0 }); }), - button(t('alertsTitle'), '', () => { - if (signinAsk()) return; - stopFeedWatch(); openNotifs(); + button('notifs', t('alertsTitle'), '', () => { + if (visitor) { toFrontDoor(); return; } + leavePage(); openNotifs(); window.scrollTo({ top: 0 }); }), - button(t('settingsWallet'), '', () => { + button('wallet', t('settingsWallet'), '', () => { if (isVisitor()) { toFrontDoor(); return; } - stopFeedWatch(); + leavePage(); + ui.txDetail = null; ui.arkMoveDetail = null; ui.arkReconDetail = null; ui.arkExitDetail = null; ui.giftDetail = null; ui.chatOpen = false; ui.msgView = null; ui.tab = 'receive'; render(); window.scrollTo({ top: 0 }); })); @@ -7644,7 +7663,6 @@ export function messagesFeature(ctx) { // the reader's language, right where a curated feed is judged by it ctx.languagePicker ? h('div', { class: 'row gap6 feed-lang', 'data-key': 'feed-lang', style: 'justify-content:center;align-items:center;padding:6px 0 2px' }, h('span', { class: 'small muted' }, t('feedLangLabel')), ctx.languagePicker()) : null, - feedBottomNav(), ...noteOverlays()); } @@ -9024,6 +9042,7 @@ export function messagesFeature(ctx) { if (!Object.keys(s.read || {}).length && !anyMsgs) return 1; return unreadCount(); }, + bottomNav: appBottomNav, notifySettingsCards() { return [notifyCard()]; }, screenView() { // A profile deep link mid-resolution holds the frame over EVERY screen diff --git a/src/style.css b/src/style.css index 3a8c036..9696e7b 100644 --- a/src/style.css +++ b/src/style.css @@ -770,20 +770,25 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } 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 sticky element inside the card would scroll away with the card. */ -.feed-page { padding-bottom: calc(82px + env(safe-area-inset-bottom, 0px)); } -.feed-bottom-nav { +#app:has(> .app-bottom-nav) { padding-bottom: calc(82px + env(safe-area-inset-bottom, 0px)); } +.app-bottom-nav { position: fixed; bottom: 0; left: 50%; transform: translateX(-50%); z-index: 30; display: flex; width: min(100%, 560px); box-sizing: border-box; padding: 6px 4px calc(6px + env(safe-area-inset-bottom, 0px)); background: var(--paper); border-top: 1px solid var(--line); box-shadow: 0 -4px 18px rgba(0, 0, 0, .06); } -.feed-nav-button { +.app-nav-button { display: flex; flex: 1; min-width: 0; flex-direction: column; align-items: center; justify-content: center; gap: 3px; min-height: 52px; padding: 4px; border: 0; background: transparent; color: var(--ink); font-size: 11px; } -.feed-nav-button > span:first-child { display: flex; } +.app-nav-button > span:first-child { display: flex; position: relative; } +.app-nav-button[aria-current="page"] { background: var(--accent-soft); } +.app-nav-button.unread > span:first-child::after { + content: ''; position: absolute; top: -2px; right: -4px; width: 7px; height: 7px; + border-radius: 50%; background: var(--green); border: 1px solid var(--paper); +} .feed-page .feed-new-pill { bottom: calc(env(safe-area-inset-bottom, 0px) + 82px); } diff --git a/tools/feed-bottom-nav-test.js b/tools/feed-bottom-nav-test.js index 53b7529..f16134c 100644 --- a/tools/feed-bottom-nav-test.js +++ b/tools/feed-bottom-nav-test.js @@ -1,5 +1,5 @@ // Real feed/navigation views with local posts and no relay traffic. -// Run: bun tools/feed-bottom-nav-test.js +// Run: bun tools/app-bottom-nav-test.js import assert from 'node:assert/strict'; import puppeteer from 'puppeteer-core'; const app = await Bun.file('src/app.js').text(); @@ -13,8 +13,8 @@ const wallet = { xpub: 'test-wallet', loaded: true, nostrRelays: () => [], loadFeatureState: (k, d) => k === 'follows' ? { tags: [['p', author]], at: Date.now()/1000 } : d, saveFeatureState() {}, registerCacheExtension() {} }; let feature; -function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView() || h('div', { class: 'wallet-screen' }, 'Wallet')]); } -feature = messagesFeature({ h, ui, wallet, render, toast() {}, hook() {}, brandHeader: () => h('div', { style: 'height:60px' }, 'Coinos') }); +function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView() || h('div', { class: 'wallet-screen' }, 'Wallet'), feature.bottomNav()]); } +feature = messagesFeature({ h, ui, wallet, render, toast() {}, hook: name => name === 'unreadMessages' ? 1 : null, brandHeader: () => h('div', { style: 'height:60px' }, 'Coinos') }); window.test = { ui, wallet, render, async feed() { ui.chatOpen = true; ui.msgView = 'feed'; ui.noteThread = null; await feature.testSeed(Array.from({length:20}, (_, i) => ({ id: i.toString(16).padStart(64,'0'), pubkey:author, kind:1, tags:[], created_at:Math.floor(Date.now()/1000)-i, @@ -40,43 +40,49 @@ const page=await browser.newPage(), errors=[]; page.on('pageerror', e=>errors.push(e.message)); await page.setRequestInterception(true); page.on('request',r=>r.url().startsWith(server.url.origin)?r.continue():r.abort()); -const scroll=async()=>{ await page.evaluate(()=>window.scrollTo(0,700)); await page.waitForFunction(()=>!document.querySelector('.feed-bottom-nav')?.hidden); }; +const scroll=async()=>{ await page.evaluate(()=>window.scrollTo(0,700)); await page.waitForFunction(()=>scrollY > 0 && document.querySelector('.app-bottom-nav')?.getBoundingClientRect().height > 0); }; try { await page.setViewport({width:390,height:844,isMobile:true,hasTouch:true}); await page.emulateMediaFeatures([{name:'prefers-reduced-motion',value:'reduce'}]); await page.goto(server.url.href); await page.waitForSelector('.note-post', {timeout:5000}); - assert(await page.$eval('.feed-bottom-nav',e=>!e.hidden && e.getBoundingClientRect().height>0),'visible at top without scrolling'); + assert(await page.$eval('.app-bottom-nav',e=>!e.hidden && e.getBoundingClientRect().height>0),'visible at top without scrolling'); await scroll(); - assert.deepEqual(await page.$$eval('.feed-nav-button',els=>els.map(e=>e.textContent)),['Home','Messages','Notifications','Wallet']); - assert(await page.$eval('.feed-bottom-nav',e=>Math.abs(e.getBoundingClientRect().bottom-innerHeight)<1),'fixed at viewport bottom'); + assert.deepEqual(await page.$$eval('.app-nav-button',els=>els.map(e=>e.textContent)),['Feed','Messages','Notifications','Wallet']); + assert(await page.$eval('.app-bottom-nav',e=>Math.abs(e.getBoundingClientRect().bottom-innerHeight)<1),'fixed at viewport bottom'); await page.evaluate(()=>test.unseen()); - assert(await page.evaluate(()=>document.querySelector('.feed-new-pill').getBoundingClientRect().bottom < document.querySelector('.feed-bottom-nav').getBoundingClientRect().top),'new posts pill stays above navigation'); - await page.screenshot({path:'/tmp/feed-bottom-nav-mobile.png'}); - await page.tap('.feed-nav-button:nth-child(1)'); - await page.waitForFunction(()=>scrollY===0 && !document.querySelector('.feed-bottom-nav').hidden); - assert.equal(await page.evaluate(()=>test.ui.msgView),'feed','Home stays in feed'); - await scroll(); await page.tap('.feed-nav-button:nth-child(2)'); - await page.waitForFunction(()=>test.ui.msgView==='home' && !document.querySelector('.feed-bottom-nav')); + assert(await page.evaluate(()=>document.querySelector('.feed-new-pill').getBoundingClientRect().bottom < document.querySelector('.app-bottom-nav').getBoundingClientRect().top),'new posts pill stays above navigation'); + await page.screenshot({path:'/tmp/app-bottom-nav-mobile.png'}); + await page.tap('.app-nav-button:nth-child(1)'); + await page.waitForFunction(()=>scrollY===0 && !document.querySelector('.app-bottom-nav').hidden); + assert.equal(await page.evaluate(()=>test.ui.msgView),'feed','Feed stays in feed'); + await scroll(); await page.tap('.app-nav-button:nth-child(2)'); + await page.waitForFunction(()=>test.ui.msgView==='home' && !!document.querySelector('.app-bottom-nav')); assert((await page.evaluate(()=>document.body.innerText)).includes('Messages'),'chat list renders'); - await page.evaluate(()=>test.feed()); await scroll(); await page.tap('.feed-nav-button:nth-child(3)'); - await page.waitForFunction(()=>test.ui.msgView==='notifs' && !document.querySelector('.feed-bottom-nav')); + assert(await page.$eval('.app-nav-button:nth-child(2)',e=>e.getAttribute('aria-current')==='page'),'messages marked active'); + await page.evaluate(()=>{test.ui.msgView='dm';test.ui.msgPeer='b'.repeat(64);test.render();}); + await page.waitForSelector('.chat-card'); + assert(await page.evaluate(()=>document.querySelector('.chat-card').getBoundingClientRect().bottom <= document.querySelector('.app-bottom-nav').getBoundingClientRect().top),'conversation and composer fit above bottom nav'); + await page.screenshot({path:'/tmp/app-nav-chat.png'}); + await page.tap('.app-nav-button:nth-child(3)'); + await page.waitForFunction(()=>test.ui.msgView==='notifs' && !!document.querySelector('.app-bottom-nav')); assert((await page.evaluate(()=>document.body.innerText)).includes('Notifications'),'notifications render'); - await page.evaluate(()=>test.feed()); await scroll(); await page.tap('.note-text'); - await page.waitForFunction(()=>!!test.ui.noteThread && !document.querySelector('.feed-bottom-nav')); - await page.evaluate(()=>test.feed()); - assert(await page.$eval('.feed-bottom-nav',e=>!e.hidden && e.getBoundingClientRect().height>0),'returning to feed restores nav immediately without a scroll'); + await page.tap('.app-nav-button:nth-child(1)'); await scroll(); await page.tap('.note-text'); + await page.waitForFunction(()=>!!test.ui.noteThread && !!document.querySelector('.app-bottom-nav')); + await page.tap('.app-nav-button:nth-child(1)'); + assert(await page.$eval('.app-bottom-nav',e=>!e.hidden && e.getBoundingClientRect().height>0),'returning to feed restores nav immediately without a scroll'); await page.evaluate(()=>{test.ui.tab='settings';}); - await page.tap('.feed-nav-button:nth-child(4)'); + await page.tap('.app-nav-button:nth-child(4)'); await page.waitForSelector('.wallet-screen'); - assert(await page.evaluate(()=>!test.ui.chatOpen && test.ui.tab==='receive' && !document.querySelector('.feed-bottom-nav')),'Wallet opens main wallet instead of old settings tab'); - await page.evaluate(()=>test.feed()); + assert(await page.evaluate(()=>!test.ui.chatOpen && test.ui.tab==='receive' && !!document.querySelector('.app-bottom-nav')),'Wallet opens main wallet instead of old settings tab'); + await page.tap('.app-nav-button:nth-child(1)'); + await page.waitForSelector('.feed-page'); await page.setViewport({width:320,height:844,isMobile:true,hasTouch:true}); - assert(await page.$eval('.feed-bottom-nav',e=>e.scrollWidth<=e.clientWidth),'four buttons fit a narrow phone'); - await page.screenshot({path:'/tmp/feed-bottom-nav-always-mobile.png'}); + assert(await page.$eval('.app-bottom-nav',e=>e.scrollWidth<=e.clientWidth),'four buttons fit a narrow phone'); + await page.screenshot({path:'/tmp/app-bottom-nav-always-mobile.png'}); await page.evaluate(()=>{test.wallet.xpub=null;test.render();}); await scroll(); - await page.tap('.feed-nav-button:nth-child(2)'); - assert(await page.evaluate(()=>test.ui.signinAsk && test.ui.msgView==='feed'),'visitor gets sign-in prompt'); + await page.tap('.app-nav-button:nth-child(2)'); + assert(await page.evaluate(()=>!test.ui.chatOpen && test.ui.msgView===null),'visitor reaches sign-in screen'); assert.deepEqual(errors,[]); - console.log('✓ Feed nav stays visible at top and on return; Home, Messages, Notifications and Wallet work; four buttons fit a narrow phone; threads, visitor sign-in and new-post pill work'); + console.log('✓ Global nav works across feed, chat list, conversations, notifications, threads and wallet; Feed restores the feed; composer remains above nav; visitor entry and narrow layout work'); } finally { await page.close(); if (remote) await browser.disconnect(); else await browser.close(); server.stop(true); } diff --git a/tools/feed-wallet-back-test.js b/tools/feed-wallet-back-test.js index cd61184..1b0abe7 100644 --- a/tools/feed-wallet-back-test.js +++ b/tools/feed-wallet-back-test.js @@ -13,12 +13,13 @@ try { await page.setRequestInterception(true); page.on('request', r => r.url().startsWith(server.url.origin) ? r.continue() : r.abort()); await page.goto(server.url.origin + '/feed', { waitUntil: 'domcontentloaded' }); - await page.waitForSelector('.feed-bottom-nav', { visible: true }); - await page.tap('.feed-nav-button:nth-child(4)'); - await page.waitForFunction(() => !document.querySelector('.feed-bottom-nav')); + await page.waitForSelector('.app-bottom-nav', { visible: true }); + await page.tap('.app-nav-button:nth-child(4)'); + await page.waitForFunction(() => !document.querySelector('.app-bottom-nav')); + assert(!(await page.$('.app-bottom-nav')), 'no nav over the welcome screen'); await page.goBack({ waitUntil: 'domcontentloaded' }); - await page.waitForSelector('.feed-bottom-nav', { visible: true, timeout: 5000 }); - assert(await page.$eval('.feed-bottom-nav', e => !e.hidden && e.getBoundingClientRect().height > 0)); + await page.waitForSelector('.app-bottom-nav', { visible: true, timeout: 5000 }); + assert(await page.$eval('.app-bottom-nav', e => !e.hidden && e.getBoundingClientRect().height > 0)); assert.equal(await page.evaluate(() => scrollY), 0, 'visible on return without scrolling'); console.log('✓ Real browser Back restores the public feed and visible navigation after Wallet'); } finally { await browser.close(); server.stop(true); } -- cgit v1.2.3 From 63e7746f98d7910053cd0b9e1a4a2ed3e6612af3 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 28 Sep 2026 21:31:16 -0700 Subject: The bottom nav spans the window; the chat list is just conversations. Feed and Notifications leave the chat list (the nav reaches both; the bell carries the notifications dot now). On a wide screen the bar runs edge to edge with its buttons over the page column; quiet muted tabs, a pill for the current one. Feed gets a newspaper icon instead of a house. Co-Authored-By: Claude Opus 5.5 --- src/features/messages.js | 50 +++++++++--------------------------------------- src/style.css | 13 ++++++++----- 2 files changed, 17 insertions(+), 46 deletions(-) (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index 7be5f26..64754d0 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -7537,15 +7537,22 @@ export function messagesFeature(ctx) { 'pos', 'lockedGift', 'viewGift', 'claimChoose', 'signinAsk']) ui[key] = null; if (!visitor) { ui.screen = 'wallet'; ui.pubProf = null; } }; + // The chat list used to carry the notifications row and its count; the + // nav's bell carries the dot now. + const notifsWaiting = () => { + if (!myPubkeys().length) return 0; + refreshNotifs(); // throttled inside; keeps the count honest + return notifUnread(); + }; const icon = (paths) => ''; const button = (id, label, paths, onClick) => h('button', { - type: 'button', class: 'app-nav-button' + (id === 'messages' && !visitor && hook('unreadMessages') ? ' unread' : ''), + type: 'button', class: 'app-nav-button' + (!visitor && (id === 'messages' ? hook('unreadMessages') : id === 'notifs' && notifsWaiting()) ? ' unread' : ''), 'aria-label': label, 'aria-current': active === id ? 'page' : undefined, onClick, }, h('span', { html: icon(paths) }), h('span', {}, label)); return h('nav', { class: 'app-bottom-nav', 'data-key': 'app-bottom-nav', }, - button('feed', t('feedTitle'), '', () => { + button('feed', t('feedTitle'), '', () => { if (active === 'feed' && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread && !ui.feedEdit) { jumpToNew(); return; } leavePage(); if (visitor) { ui.pubProf = true; openFirehose(); } else showFeed(curFeedId); @@ -8202,45 +8209,6 @@ export function messagesFeature(ctx) { if (st().noPushService && !st().push) kids.push(h('div', { class: 'notice info small' }, pushAdvice())); - // ---- the feed, above the conversations: it's the thing you read, they're - // the things you answer - kids.push(h('div', { class: 'list' }, - h('div', { - class: 'item chat-thread-row', - onClick: () => { ui.msgView = 'feed'; switchFeed(FOLLOWING); }, - }, - h('div', { class: 'chat-avatar fallback' }, '\u2605'), - h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, - h('span', { class: 'chat-name' }, t('feedTitle')), - h('div', { class: 'muted small' }, - followsNow().set.size === 1 ? t('feedFollowing1') - : followsNow().set.size ? t('feedFollowingN', { n: followsNow().set.size }) - : t('feedNoFollowsShort')))), - // ...the feeds you made, each its own row under it - ...st().feeds.map((f) => h('div', { - class: 'item chat-thread-row', - onClick: () => { ui.msgView = 'feed'; switchFeed(f.id); }, - }, - h('div', { class: 'chat-avatar fallback' }, feedTopics(f).length && !f.follows && !(f.authors || []).length && !(f.packs || []).length ? '#' : '\u2605'), - h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, - h('span', { class: 'chat-name' }, f.name), - h('div', { class: 'muted small chat-preview' }, feedSummary(f))))), - // ...and what happened to what you posted - (() => { - const n = myPubkeys().length ? notifUnread() : 0; - if (myPubkeys().length) refreshNotifs(); // throttled inside; keeps the count honest - return h('div', { - class: 'item chat-thread-row' + (n ? ' unread' : ''), - onClick: openNotifs, - }, - h('div', { class: 'chat-avatar fallback' }, '\ud83d\udd14'), - h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, - h('span', { class: 'chat-name' }, t('alertsTitle')), - h('div', { class: 'muted small' }, n ? t('alertsNew', { n }) : t('alertsSub'))), - // like a group row: the dot sits mid-height, padded off the edge - n ? h('i', { class: 'thread-dot' }) : null); - })())); - // ---- DMs kids.push(h('div', { class: 'row between', style: 'align-items:baseline' }, h('h3', { style: 'margin:0' }, t('msgDmsTitle')), diff --git a/src/style.css b/src/style.css index 9696e7b..c873ccb 100644 --- a/src/style.css +++ b/src/style.css @@ -772,19 +772,22 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } sticky element inside the card would scroll away with the card. */ #app:has(> .app-bottom-nav) { padding-bottom: calc(82px + env(safe-area-inset-bottom, 0px)); } .app-bottom-nav { - position: fixed; bottom: 0; left: 50%; transform: translateX(-50%); - z-index: 30; display: flex; width: min(100%, 560px); box-sizing: border-box; - padding: 6px 4px calc(6px + env(safe-area-inset-bottom, 0px)); + /* Edge to edge like a system bar; the buttons stay over the page column. */ + position: fixed; bottom: 0; left: 0; right: 0; + z-index: 30; display: flex; justify-content: center; box-sizing: border-box; + padding: 6px max(4px, calc((100% - 560px) / 2)) calc(6px + env(safe-area-inset-bottom, 0px)); background: var(--paper); border-top: 1px solid var(--line); box-shadow: 0 -4px 18px rgba(0, 0, 0, .06); } .app-nav-button { display: flex; flex: 1; min-width: 0; flex-direction: column; align-items: center; justify-content: center; gap: 3px; min-height: 52px; padding: 4px; - border: 0; background: transparent; color: var(--ink); font-size: 11px; + border: 0; border-radius: 12px; background: transparent; color: var(--muted); font-size: 11px; + cursor: pointer; transition: color .15s ease, background-color .15s ease; } +@media (hover: hover) { .app-nav-button:hover { color: var(--ink); } } .app-nav-button > span:first-child { display: flex; position: relative; } -.app-nav-button[aria-current="page"] { background: var(--accent-soft); } +.app-nav-button[aria-current="page"] { background: var(--accent-soft); color: var(--ink); font-weight: 600; } .app-nav-button.unread > span:first-child::after { content: ''; position: absolute; top: -2px; right: -4px; width: 7px; height: 7px; border-radius: 50%; background: var(--green); border: 1px solid var(--paper); -- cgit v1.2.3 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/features/messages.js | 21 ++++++++++++++------- src/i18n.js | 1 + tools/feed-bottom-nav-test.js | 17 ++++++++++++++--- 3 files changed, 29 insertions(+), 10 deletions(-) (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index 64754d0..90410fb 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -8244,11 +8244,10 @@ export function messagesFeature(ctx) { ? h('div', { class: 'list' }, resultRows(h, dmSearch.rows, (r) => openThread(r.pk), (pk, node) => hook('wrapAvatar', pk, node))) : h('div', { class: 'small muted' }, t('msgNoMatches')))); } - // A long DM history must not bury the communities below it: past a - // handful, the rest waits behind "show all". (No cap for barely-over — - // a "show 2 more" button costs more than the rows it hides.) - const DM_PREVIEW = 5; - const shownDms = (ui.msgAllDms || dmRows.length <= DM_PREVIEW + 2) ? dmRows : dmRows.slice(0, DM_PREVIEW); + // A long DM history must not bury the communities below it: past four, + // the rest waits behind "show all". Communities get the same cap. + const LIST_PREVIEW = 4; + const shownDms = ui.msgAllDms ? dmRows : dmRows.slice(0, LIST_PREVIEW); kids.push( dmRows.length ? h('div', { class: 'list' }, shownDms.map(({ peer, last, unread }) => @@ -8268,7 +8267,7 @@ export function messagesFeature(ctx) { if (shownDms.length < dmRows.length) kids.push(h('button', { class: 'linklike small', onClick: () => { ui.msgAllDms = true; render(); } }, t('msgShowAllDms', { n: dmRows.length }))); - else if (ui.msgAllDms && dmRows.length > DM_PREVIEW + 2) + else if (ui.msgAllDms && dmRows.length > LIST_PREVIEW) kids.push(h('button', { class: 'linklike small', onClick: () => { ui.msgAllDms = false; render(); } }, t('msgShowFewerDms'))); @@ -8305,7 +8304,9 @@ export function messagesFeature(ctx) { value: ui.msgNewName || '', onInput: (e) => { ui.msgNewName = e.target.value; }, }), h('button', { class: 'btn-sm', onClick: () => createCommunity(ui.msgNewName || '') }, t('msgCreate')))); - kids.push(h('div', { class: 'list' }, communities().map((jm) => { + const allRooms = communities(); + const shownRooms = ui.msgAllRooms ? allRooms : allRooms.slice(0, LIST_PREVIEW); + kids.push(h('div', { class: 'list' }, shownRooms.map((jm) => { const room = rooms.get(jm.community_id); const name = room?.folded?.metadata?.name || jm.name; // Last session's settled count anchors the number: while the guestbook @@ -8329,6 +8330,12 @@ export function messagesFeature(ctx) { // the row's own centring puts the dot mid-height, off the edge unread ? h('i', { class: 'thread-dot' }) : null); }))); + if (shownRooms.length < allRooms.length) + kids.push(h('button', { class: 'linklike small', onClick: () => { ui.msgAllRooms = true; render(); } }, + t('msgShowAllCommunities', { n: allRooms.length }))); + else if (ui.msgAllRooms && allRooms.length > LIST_PREVIEW) + kids.push(h('button', { class: 'linklike small', onClick: () => { ui.msgAllRooms = false; render(); } }, + t('msgShowFewerDms'))); return h('div', { class: 'card col chat-page', style: 'gap:10px' }, ...kids); } 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', diff --git a/tools/feed-bottom-nav-test.js b/tools/feed-bottom-nav-test.js index f16134c..4354e97 100644 --- a/tools/feed-bottom-nav-test.js +++ b/tools/feed-bottom-nav-test.js @@ -15,7 +15,7 @@ const wallet = { xpub: 'test-wallet', loaded: true, nostrRelays: () => [], let feature; function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView() || h('div', { class: 'wallet-screen' }, 'Wallet'), feature.bottomNav()]); } feature = messagesFeature({ h, ui, wallet, render, toast() {}, hook: name => name === 'unreadMessages' ? 1 : null, brandHeader: () => h('div', { style: 'height:60px' }, 'Coinos') }); -window.test = { ui, wallet, render, async feed() { +window.test = { ui, wallet, render, get feature() { return feature; }, async feed() { ui.chatOpen = true; ui.msgView = 'feed'; ui.noteThread = null; await feature.testSeed(Array.from({length:20}, (_, i) => ({ id: i.toString(16).padStart(64,'0'), pubkey:author, kind:1, tags:[], created_at:Math.floor(Date.now()/1000)-i, content: 'A post in the feed. Scroll to keep Home, messages and notifications within reach. '.repeat(3) }))); @@ -26,7 +26,7 @@ await test.feed(); const bundle = await Bun.build({ entrypoints: ['nav-test-entry'], target: 'browser', plugins: [{ name: 'nav-test', setup(build) { build.onResolve({filter:/^nav-test-entry$/},()=>({path:'entry',namespace:'nav-test'})); build.onLoad({filter:/.*/,namespace:'nav-test'},()=>({contents:entry,loader:'js',resolveDir:process.cwd()})); - build.onLoad({filter:/src\/features\/messages\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()).replace("id: 'messages',", "id: 'messages', testSeed: async (notes) => { const c = feedNow(); await c.boot; Object.assign(c, { notes, status: 'ready', booting: false, end: true, shown: 20 }); }, testUnseen: () => { feed.unseen = 2; },")})); + build.onLoad({filter:/src\/features\/messages\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()).replace("id: 'messages',", "id: 'messages', testSeed: async (notes) => { const c = feedNow(); await c.boot; Object.assign(c, { notes, status: 'ready', booting: false, end: true, shown: 20 }); }, testUnseen: () => { feed.unseen = 2; }, testChats: (n, m) => { stCache = null; if (m != null) st().communities = Array.from({ length: m }, (_, i) => ({ ...COMMUNITY, community_id: (i + 1).toString(16).padStart(64, 'd'), name: 'Room ' + i })); threads.clear(); for (let i = 0; i < n; i++) { const peer = (i + 1).toString(16).padStart(64, 'c'); threads.set(peer, new Map([['r' + i, { rumor: { id: 'r' + i, kind: 14, pubkey: peer, tags: [], content: 'hi ' + i, created_at: 1700000000 - i }, mine: false }]])); } },")})); build.onLoad({filter:/src\/nostr\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()) .replace('export async function queryOn(relays, filter, maxWait = 1500) {','export async function queryOn(relays, filter, maxWait = 1500) { return [];') .replace(/export function subscribeOn\(([^)]*)\) \{/,'export function subscribeOn($1) { return () => {};')})); @@ -60,6 +60,17 @@ try { await page.waitForFunction(()=>test.ui.msgView==='home' && !!document.querySelector('.app-bottom-nav')); assert((await page.evaluate(()=>document.body.innerText)).includes('Messages'),'chat list renders'); assert(await page.$eval('.app-nav-button:nth-child(2)',e=>e.getAttribute('aria-current')==='page'),'messages marked active'); + const rows=()=>page.evaluate(()=>{const ls=[...document.querySelectorAll('.chat-page > .list')];return ls.map(l=>l.querySelectorAll('.chat-thread-row').length);}); + const links=()=>page.$$eval('.chat-page > button.linklike',els=>els.map(e=>e.textContent)); + await page.evaluate(()=>{const m={};const orig=test.wallet.loadFeatureState;test.wallet.loadFeatureState=(k,d)=>k==='messages'?m:orig(k,d);test.feature.testChats(4,4);test.render();}); + await page.waitForFunction(()=>document.querySelectorAll('.chat-page .chat-thread-row').length>=8); + assert.deepEqual(await rows(),[4,4]); assert.deepEqual(await links(),['Show all 5 communities'],'four DMs show whole; five communities are gated'); + await page.evaluate(()=>{test.feature.testChats(9);test.render();}); + assert.deepEqual(await rows(),[4,4]); assert.deepEqual(await links(),['Show all 9 conversations','Show all 5 communities']); + await page.$$eval('.chat-page > button.linklike',els=>els.forEach(e=>e.click())); + assert.deepEqual(await rows(),[9,5]); assert.deepEqual(await links(),['Show fewer','Show fewer']); + await page.$$eval('.chat-page > button.linklike',els=>els.forEach(e=>e.click())); + assert.deepEqual(await rows(),[4,4],'show fewer folds both lists back'); await page.evaluate(()=>{test.ui.msgView='dm';test.ui.msgPeer='b'.repeat(64);test.render();}); await page.waitForSelector('.chat-card'); assert(await page.evaluate(()=>document.querySelector('.chat-card').getBoundingClientRect().bottom <= document.querySelector('.app-bottom-nav').getBoundingClientRect().top),'conversation and composer fit above bottom nav'); @@ -84,5 +95,5 @@ try { await page.tap('.app-nav-button:nth-child(2)'); assert(await page.evaluate(()=>!test.ui.chatOpen && test.ui.msgView===null),'visitor reaches sign-in screen'); assert.deepEqual(errors,[]); - console.log('✓ Global nav works across feed, chat list, conversations, notifications, threads and wallet; Feed restores the feed; composer remains above nav; visitor entry and narrow layout work'); + console.log('✓ Global nav works across feed, chat list, conversations, notifications, threads and wallet; Feed restores the feed; composer remains above nav; visitor entry and narrow layout work; DMs and communities cap at four behind Show all'); } finally { await page.close(); if (remote) await browser.disconnect(); else await browser.close(); server.stop(true); } -- cgit v1.2.3 From 613ab78f972277e963b7c7e49c3ffb1880bdd9d4 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 28 Sep 2026 21:49:18 -0700 Subject: Zapping someone without Ark just works: Lightning first, then a locked gift that looks like any zap. No warning and no gift wording: when a person has no Ark address and no Lightning address, the ordinary amount + Zap form sends a gift locked to their nostr key and DMs it; the receipt screen is the usual Zapped!, with the link and code only if the DM couldn't be delivered. One-tap post zaps take the same path instead of failing. The Lightning button appears only for someone who has a Lightning address, and an Ark advert for another network no longer stops the lookup short of Lightning. Co-Authored-By: Claude Opus 5.5 --- src/features/ark.js | 128 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 75 insertions(+), 53 deletions(-) (limited to 'src/features') diff --git a/src/features/ark.js b/src/features/ark.js index d733e0b..566c402 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -1667,7 +1667,8 @@ export function arkFeature(ctx) { const adv = await lookupArkZapTarget(pk).catch(() => ({ status: 'noark' })); if (!live()) return; if (adv.status === 'ready') { Object.assign(z, adv); if (z.autoSat) return auto(); render(); return; } - if (adv.status === 'wrongnet') { Object.assign(z, adv); bail(t('arkGiftWrongNet', { net: adv.net })); render(); return; } + // An advert for another network is no address here: keep looking. + if (adv.status === 'wrongnet') z.net = adv.net; // 1b. BIP-353 with an ark instruction; remember on-chain as last resort const uris = []; try { @@ -1699,12 +1700,14 @@ export function arkFeature(ctx) { } // 2. lightning via the zap flow (connect first — canLnPay needs it) if (profile && (profile.lud16 || profile.lud06)) { + z.lnOk = true; await connectArk().catch(() => {}); if (!live()) return; if (ctx.hook('canLnZap')) { if (ui.arkZap === z) ui.arkZap = null; ctx.hook('lnZapNpub', pk, npub, z.eventId, z.autoSat); return; } } - // 3. on-chain fallback - if (onchain) { + // 3. on-chain fallback (a one-tap zap can't take the send form: it + // goes as a locked gift below instead) + if (onchain && !(z.autoSat && z.eventId && zapGiftOk())) { if (z.autoSat && z.eventId) { bail(); render(); return; } ui.arkZap = null; ui.send.recipients[0].address = onchain; @@ -1712,7 +1715,23 @@ export function arkFeature(ctx) { render(); return; } - if (live()) { z.status = 'noark'; bail(); render(); } + if (!live()) return; + z.status = z.net ? 'wrongnet' : 'noark'; + // 4. last resort: an ark gift locked to their nostr key, DMed to them. + // To the payer it's just a zap. + if (z.autoSat && zapGiftOk(z.autoSat)) { + try { + await performArkZapGift(z, z.autoSat); + if (ui.arkZap === z) ui.arkZap = null; + ctx.hook('zapSettled', z.eventId, true, z.autoSat); + if (!z.eventId) toast('⚡ ' + t('zapSentShort', { n: fmtAmount(z.autoSat) + ' ' + unitLabel() })); + } catch (e) { + if (!bail(e.message)) ui.sendError = e.message; + } + render(); + return; + } + bail(z.net ? t('arkGiftWrongNet', { net: z.net }) : undefined); render(); })().catch((e) => { if (live()) { z.status = 'noark'; if (!bail(e.message)) ui.sendError = e.message; render(); } }); } @@ -1828,6 +1847,34 @@ export function arkFeature(ctx) { // safe to put in the receipt because only the recipient's key — or the // claim code we DM them — opens it. Unclaimed, it stays revocable from the // gift card like any other ark gift. + // Can a zap to this person leave as a locked gift (this build carries + // gifts, the wallet can sign, and Spending covers it)? + function zapGiftOk(sats = 330) { + return !wallet.watchOnly && (arkBalance()?.spendableSat || 0) >= Math.max(330, sats) && !!ctx.hook('canLockGift'); + } + async function performArkZapGift(z, sats) { + const g = await createArkGift(sats, z.pk); + const locked = ctx.hook('lockArkGift', g.code, sats, z.pk); + if (!locked) throw new Error(t('claimFailed')); // no gifts feature in this build + noteZap('to:' + g.address, z.pk); + const gift = { url: locked.url, claimCode: locked.claimCode, dm: 'sending' }; + // best-effort receipt; carries the locked link so the recipient can + // discover the gift from the note even if the DM never lands + wallet.nostrPublish({ + kind: ARK_ZAP_KIND, + content: (z.comment || '').slice(0, 280), + tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), + ['amount', String(sats)], ['network', getNetwork()], ['gift', locked.url], ...zapSenderTag()], + }).catch(() => {}); + const dmText = t('giftDmText', { amount: fmtAmount(sats) + ' ' + unitLabel(), link: locked.url, code: locked.claimCode }); + if (wallet.sendNostrDM) { + wallet.sendNostrDM(z.pk, dmText) + .then((ok) => { gift.dm = ok ? 'sent' : 'failed'; render(); }) + .catch(() => { gift.dm = 'failed'; render(); }); + } else gift.dm = 'failed'; + return gift; + } + async function doArkZapGift() { const z = ui.arkZap; const sats = ctx.parseAmount(z.amount, ctx.getUnit()); @@ -1835,27 +1882,11 @@ export function arkFeature(ctx) { if (sats > (arkBalance()?.spendableSat || 0)) { ui.sendError = t('giftExceedsBalance'); render(); return; } ui.busy = true; ui.sendError = ''; render(); try { - const g = await createArkGift(sats, z.pk); - const locked = ctx.hook('lockArkGift', g.code, sats, z.pk); - if (!locked) throw new Error(t('claimFailed')); // no gifts feature in this build - noteZap('to:' + g.address, z.pk); - const done = (ui.arkZapped = { amountSat: sats, npub: z.npub, gift: { url: locked.url, claimCode: locked.claimCode, dm: 'sending' } }); + const gift = await performArkZapGift(z, sats); + ctx.hook('zapSettled', z.eventId, true, sats); + ui.arkZapped = { amountSat: sats, npub: z.npub, gift }; ui.arkZap = null; ui.send = blankSend(); - // best-effort receipt; carries the locked link so the recipient can - // discover the gift from the note even if the DM never lands - await wallet.nostrPublish({ - kind: ARK_ZAP_KIND, - content: (z.comment || '').slice(0, 280), - tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), - ['amount', String(sats)], ['network', getNetwork()], ['gift', locked.url], ...zapSenderTag()], - }).catch(() => {}); - const dmText = t('giftDmText', { amount: fmtAmount(sats) + ' ' + unitLabel(), link: locked.url, code: locked.claimCode }); - if (wallet.sendNostrDM) { - wallet.sendNostrDM(z.pk, dmText) - .then((ok) => { done.gift.dm = ok ? 'sent' : 'failed'; render(); }) - .catch(() => { done.gift.dm = 'failed'; render(); }); - } else done.gift.dm = 'failed'; } catch (e) { ui.sendError = e.message; } @@ -1870,7 +1901,9 @@ export function arkFeature(ctx) { if (ui.arkZapped) { const gift = ui.arkZapped.gift; const finish = () => { ui.arkZapped = null; ui.send = blankSend(); render(); }; - if (!gift) { + // A gift zap looks like any other zap. Only a DM that couldn't be + // delivered needs the payer: the link and code to pass along by hand. + if (!gift || gift.dm !== 'failed') { return h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;cursor:pointer;padding:48px 20px', @@ -1881,29 +1914,25 @@ export function arkFeature(ctx) { h('div', { class: 'amount-neg', style: 'font-size:18px' }, '-' + fmtAmount(ui.arkZapped.amountSat) + ' ' + unitLabel()), h('div', { class: 'small muted' }, t('tapToProceed'))); } - // A locked-gift zap has aftercare: the DM's fate, and the link + claim - // code to pass along by hand when the DM couldn't be delivered. return h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px;padding:28px 16px' }, - h('div', { class: 'check-badge' }, '🎁'), - h('h2', { style: 'margin:0' }, t('arkZapGiftSentTitle')), + h('div', { class: 'check-badge' }, '⚡'), + h('h2', { style: 'margin:0' }, t('arkZapSentTitle')), h('div', { class: 'amount-neg', style: 'font-size:18px' }, '-' + fmtAmount(ui.arkZapped.amountSat) + ' ' + unitLabel()), - gift.dm === 'sending' - ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('giftDmSending'))) - : gift.dm === 'sent' - ? h('div', { class: 'small', style: 'color:var(--green)' }, t('giftDmSent')) - : h('div', { class: 'notice info', style: 'text-align:left' }, t('giftDmFailed')), + h('div', { class: 'notice info', style: 'text-align:left' }, t('giftDmFailed')), h('div', { class: 'addr-box break', style: 'width:100%;font-size:11px' }, gift.url), h('div', { class: 'row gap6 wrap', style: 'justify-content:center' }, copyBtn(gift.url, t('copyLink')), - gift.dm !== 'sent' ? copyBtn(gift.claimCode, t('giftCopyCode')) : null), + copyBtn(gift.claimCode, t('giftCopyCode'))), h('button', { class: 'btn-primary btn-block', onClick: finish }, t('done'))); } const z = ui.arkZap; if (!z) return null; const spendable = arkBalance()?.spendableSat || 0; - // No ark address found — but a zap can still leave as an ark gift locked - // to their nostr key, when this build carries the gifts feature. - const giftOk = z.status === 'noark' && !wallet.watchOnly && spendable >= 1 && !!ctx.hook('canLockGift'); + // No ark address or Lightning found — the zap still leaves, as an ark + // gift locked to their nostr key. The payer sees an ordinary zap. + const noAddr = z.status === 'noark' || z.status === 'wrongnet'; + const giftOk = noAddr && !wallet.watchOnly && !!ctx.hook('canLockGift'); + const payable = z.status === 'ready' || giftOk; const amountInputs = (hint) => h('div', { class: 'col gap6' }, h('div', { class: 'input-group' }, h('input', { type: 'number', min: '0', inputmode: 'decimal', placeholder: t('lnPayAmount'), value: z.amount, @@ -1918,31 +1947,24 @@ export function arkFeature(ctx) { ctx.hook('profileChip', z.pk, 'lg') || h('div', { class: 'small muted', style: 'word-break:break-all' }, z.npub), z.status === 'lookup' ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkZapLookup'))) : null, z.status === 'noark' && !giftOk ? h('div', { class: 'notice err' }, t('arkZapNoArk')) : null, - giftOk ? h('div', { class: 'notice info' }, t('arkZapNoArkGift')) : null, - giftOk ? amountInputs(t('arkZapGiftHint')) : null, - giftOk - ? (ui.busy - ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' })) - : h('button', { class: 'btn-primary btn-block', onClick: doArkZapGift }, '🎁 ' + t('arkZapGiftBtn'))) - : null, - z.status === 'wrongnet' ? h('div', { class: 'notice err' }, t('arkGiftWrongNet', { net: z.net })) : null, - // No Ark address, but they may still take a Lightning zap — hand off to - // the zaps feature (present alongside swaps). - (z.status === 'noark' || z.status === 'wrongnet') && ctx.hook('canLnZap') - ? h('button', { class: (giftOk ? '' : 'btn-primary ') + 'btn-block', disabled: !!ui.busy, onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub, z.eventId); } }, '⚡ ' + t('lnZapFallback')) + z.status === 'wrongnet' && !giftOk ? h('div', { class: 'notice err' }, t('arkGiftWrongNet', { net: z.net })) : null, + // Only someone with a Lightning address gets the Lightning door (the + // lookup normally hands such a person straight to the zaps feature). + noAddr && z.lnOk && ctx.hook('canLnZap') + ? h('button', { class: 'btn-block', disabled: !!ui.busy, onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub, z.eventId); } }, '⚡ ' + t('lnZapFallback')) : null, - z.status === 'ready' && spendable < 330 + payable && spendable < 330 ? h('div', { class: 'notice info' }, t('zapNoBalance')) - : z.status === 'ready' + : payable ? amountInputs(t('arkZapHint')) : null, ui.sendError ? h('div', { class: 'notice err' }, ui.sendError) : null, h('div', { class: 'row gap6' }, h('button', { class: 'btn-ghost', onClick: () => { ui.arkZap = null; ui.sendError = ''; ui.send = blankSend(); render(); } }, t('back')), - z.status === 'ready' && spendable >= 330 + payable && spendable >= 330 ? (ui.busy ? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner' })) - : h('button', { class: 'btn-primary grow', onClick: doArkZap }, t('arkZapBtn'))) + : h('button', { class: 'btn-primary grow', onClick: z.status === 'ready' ? doArkZap : doArkZapGift }, t('arkZapBtn'))) : null)); } -- cgit v1.2.3 From b1397fc6b0c6b91a0835911b9dd52c0b09432d56 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 28 Sep 2026 22:17:56 -0700 Subject: New posts: the pill lands on the oldest one, and each wears a tint until you've seen it. The new-posts pill used to glide to the top of the feed; now it stops at the oldest post that came in, right above where you were reading, so you read on upward. Posts that arrive while you read are tinted until they've been in view about two seconds, then fade to the plain page (like Amethyst). Co-Authored-By: Claude Opus 5.5 --- src/features/messages.js | 83 ++++++++++++++++++++++++++++++++++++++++---- src/style.css | 9 +++++ tools/feed-new-posts-test.js | 74 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 tools/feed-new-posts-test.js (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index 90410fb..8c8bb15 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -4299,7 +4299,7 @@ export function messagesFeature(ctx) { ui.feedId = id; stopFeedWatch(); const c = feedNow(); - c.unseen = 0; c.shown = FEED_PAGE; + c.unseen = 0; c.fresh = null; c.shown = FEED_PAGE; admitFeed(c.deferred || [], c, true); // An already warmed feed can paint synchronously on entry. if (!c.booting) c.presentations.clear(); @@ -4683,10 +4683,45 @@ export function messagesFeature(ctx) { } const prev = feedPaint; feedPaint = presentation; - try { return keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey)), ev.id); } + try { + const row = keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey)), ev.id); + if (c.fresh && c.fresh.has(ev.id)) { row.classList.add('note-fresh'); watchFresh(c); } + return row; + } finally { feedPaint = prev; } } + // New posts stay tinted until they've been in view for FRESH_SEEN_MS: a + // row at least half on screen (or filling most of it) starts its clock, + // leaving view stops it. Seen, it loses the tint in place — no repaint. + const FRESH_SEEN_MS = 2000; + let freshObserver = null; + const freshTimers = new Map(); + function watchFresh(c) { + if (typeof IntersectionObserver === 'undefined') return; + freshObserver ||= new IntersectionObserver((entries) => { + for (const en of entries) { + const el = en.target, id = el.getAttribute('data-key'); + const inView = en.isIntersecting && (en.intersectionRatio >= 0.5 || en.intersectionRect.height >= window.innerHeight * 0.5); + if (inView && !freshTimers.has(id)) { + freshTimers.set(id, setTimeout(() => { + freshTimers.delete(id); + if (!el.isConnected) return; + feed?.fresh?.delete(id); + el.classList.remove('note-fresh'); + freshObserver.unobserve(el); + }, FRESH_SEEN_MS)); + } else if (!inView && freshTimers.has(id)) { + clearTimeout(freshTimers.get(id)); freshTimers.delete(id); + } + } + }, { threshold: [0, 0.5, 1] }); + // after the paint, when the row nodes are the mounted ones + requestAnimationFrame(() => { + for (const el of document.querySelectorAll('.notes-feed > .row.note-fresh')) freshObserver.observe(el); + }); + } + // Posts at the door: filtered in synchronously, so the same note from a // second relay is dropped while the first copy is still warming. const feedStaged = new WeakMap(); @@ -4740,20 +4775,56 @@ export function messagesFeature(ctx) { for (const id of c.presentations.keys()) if (!retained.has(id)) c.presentations.delete(id); saveFeedCache(c); if (rows.length) { - c.unseen = (c.unseen || 0) + result.added.filter((e) => e.created_at > topBefore).length; + const fresh = result.added.filter((e) => e.created_at > topBefore); + c.unseen = (c.unseen || 0) + fresh.length; + c.fresh ||= new Set(); + for (const e of fresh) c.fresh.add(e.id); holdScroll(render); } } - // The pill's tap: the posts are already in, so this just goes up to them. - // Reaching the top by yourself clears it too (see the scroll listener). + // The pill's tap: the posts are already in, so this just goes up to them — + // to the OLDEST of them, the one right above where you were reading, so + // you read on upward through the rest. Reaching the top by yourself clears + // the pill too (see the scroll listener). function jumpToNew() { const c = feed; if (!c) return; admitFeed(c.deferred || [], c, true); c.unseen = 0; render(); - glideToTop(); + const list = c.winList ? c.winList() : []; + let i = -1; + for (let j = list.length - 1; j >= 0; j--) if (c.fresh && c.fresh.has(list[j].id)) { i = j; break; } + if (i < 0) { glideToTop(); return; } + glideToPost(c, list, i); + } + // Scroll so post i's top sits just under the top of the window. A row + // outside the rendered window is placed from the height cache first, then + // corrected once it's mounted. + function glideToPost(c, list, i) { + const id = list[i].id, MARGIN = 8; + const find = () => document.querySelector('.notes-feed > [data-key="' + CSS.escape(id) + '"]'); + try { + const H = window.innerHeight || 800; + if (!find() && c.heights) { + let sum = 0, n = 0; + for (const v of c.heights.values()) { sum += v; n++; } + const avg = n ? sum / n : 200; + let y = 0; + for (let j = 0; j < i; j++) y += (c.heights.get(list[j].id) || avg) + ROW_GAP; + window.scrollTo(0, Math.max(0, (c.listTop || 0) + y - MARGIN)); + render(); + } + const el = find(); + if (!el) { glideToTop(); return; } + const target = Math.max(0, window.scrollY + el.getBoundingClientRect().top - MARGIN); + const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (reduce) { window.scrollTo({ top: target }); return; } + // a long way off: close most of the distance in one step, glide the rest + if (Math.abs(window.scrollY - target) > H * 2) window.scrollTo({ top: target + Math.sign(window.scrollY - target) * H * 2 }); + requestAnimationFrame(() => { try { window.scrollTo({ top: target, behavior: 'smooth' }); } catch { window.scrollTo(0, target); } }); + } catch { glideToTop(); } } // Up to the new posts as a glide, not a cut: an instant scrollTo read as a // page refresh. A long way down, most of the distance is closed in one diff --git a/src/style.css b/src/style.css index dc116a3..e907bd5 100644 --- a/src/style.css +++ b/src/style.css @@ -903,6 +903,15 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } border-left: none; border-right: none; border-radius: 0; box-shadow: none; } +/* A post that arrived while you were reading wears a tint until it has been + on screen a couple of seconds; then it fades to the plain page. The side + shadows carry the tint through the list's padding to its edges. */ +.notes-feed > .row { transition: background-color .9s ease, box-shadow .9s ease; } +.notes-feed > .row.note-fresh { + --fresh-bg: color-mix(in srgb, var(--green) 9%, var(--paper)); + background-color: var(--fresh-bg); + box-shadow: -14px 0 0 var(--fresh-bg), 14px 0 0 var(--fresh-bg); +} /* Inline move-money panel on the balance card. */ diff --git a/tools/feed-new-posts-test.js b/tools/feed-new-posts-test.js new file mode 100644 index 0000000..c90f0d5 --- /dev/null +++ b/tools/feed-new-posts-test.js @@ -0,0 +1,74 @@ +// New posts arriving mid-read: the pill lands on the OLDEST new post, and +// each new post keeps a tint until it has been in view a couple of seconds. +// Run: bun tools/feed-new-posts-test.js +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer-core'; +const app = await Bun.file('src/app.js').text(); +const dom = app.slice(app.indexOf('function h(tag,'), app.indexOf("const root = document.getElementById('app');")); +const entry = ` +import { messagesFeature } from './src/features/messages.js'; +${dom} +const author = 'a'.repeat(64); +const ui = { screen: 'wallet', chatOpen: true, msgView: 'feed' }; +const wallet = { xpub: 'test-wallet', loaded: true, nostrRelays: () => [], + loadFeatureState: (k, d) => k === 'follows' ? { tags: [['p', author]], at: Date.now()/1000 } : d, + saveFeatureState() {}, registerCacheExtension() {} }; +let feature; +function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView() || h('div', { class: 'wallet-screen' }, 'Wallet'), feature.bottomNav()]); } +feature = messagesFeature({ h, ui, wallet, render, toast() {}, hook: name => name === 'unreadMessages' ? 1 : null, brandHeader: () => h('div', { style: 'height:60px' }, 'Coinos') }); +window.test = { ui, wallet, render, get feature() { return feature; }, async feed() { + ui.chatOpen = true; ui.msgView = 'feed'; ui.noteThread = null; + await feature.testSeed(Array.from({length:20}, (_, i) => ({ id: i.toString(16).padStart(64,'0'), pubkey:author, kind:1, tags:[], created_at:Math.floor(Date.now()/1000)-i, + content: 'A post in the feed. Scroll to keep Home, messages and notifications within reach. '.repeat(3) }))); + render(); window.scrollTo(0,0); +}, unseen() { feature.testUnseen(); render(); }, arrive(n) { const now = Math.floor(Date.now()/1000); feature.testArrive(Array.from({length:n}, (_, i) => ({ id: 'f' + i.toString(16).padStart(63,'0'), pubkey:author, kind:1, tags:[], created_at: now + 100 - i, content: 'Brand new post number ' + i + '. '.repeat(1) + 'Fresh words. '.repeat(20) }))); } }; +await test.feed(); +`; +const bundle = await Bun.build({ entrypoints: ['nav-test-entry'], target: 'browser', plugins: [{ name: 'nav-test', setup(build) { + build.onResolve({filter:/^nav-test-entry$/},()=>({path:'entry',namespace:'nav-test'})); + build.onLoad({filter:/.*/,namespace:'nav-test'},()=>({contents:entry,loader:'js',resolveDir:process.cwd()})); + build.onLoad({filter:/src\/features\/messages\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()).replace("id: 'messages',", "id: 'messages', testSeed: async (notes) => { const c = feedNow(); await c.boot; Object.assign(c, { notes, status: 'ready', booting: false, end: true, shown: 20 }); }, testUnseen: () => { feed.unseen = 2; }, testArrive: (notes) => admitFeed(notes, feed), testChats: (n, m) => { stCache = null; if (m != null) st().communities = Array.from({ length: m }, (_, i) => ({ ...COMMUNITY, community_id: (i + 1).toString(16).padStart(64, 'd'), name: 'Room ' + i })); threads.clear(); for (let i = 0; i < n; i++) { const peer = (i + 1).toString(16).padStart(64, 'c'); threads.set(peer, new Map([['r' + i, { rumor: { id: 'r' + i, kind: 14, pubkey: peer, tags: [], content: 'hi ' + i, created_at: 1700000000 - i }, mine: false }]])); } },")})); + build.onLoad({filter:/src\/nostr\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()) + .replace('export async function queryOn(relays, filter, maxWait = 1500) {','export async function queryOn(relays, filter, maxWait = 1500) { return [];') + .replace(/export function subscribeOn\(([^)]*)\) \{/,'export function subscribeOn($1) { return () => {};')})); +} }] }); +assert(bundle.success,bundle.logs.join('\n')); +const html=`
`; + +const server=Bun.serve({port:0,fetch:()=>new Response(html,{headers:{'content-type':'text/html'}})}); +const browser=await puppeteer.launch({executablePath:'/usr/bin/google-chrome',headless:true,args:['--no-sandbox']}); +const page=await browser.newPage(), errors=[]; +page.on('pageerror', e=>errors.push(e.message)); +await page.setRequestInterception(true); +page.on('request',r=>r.url().startsWith(server.url.origin)?r.continue():r.abort()); +const sleep=(ms)=>new Promise(r=>setTimeout(r,ms)); +const top=(id)=>page.evaluate((id)=>{const r=document.querySelector('.notes-feed > [data-key="'+id+'"]');return r?Math.round(r.getBoundingClientRect().top):null;},id); +const fresh=()=>page.$$eval('.notes-feed > .row.note-fresh',els=>els.map(e=>e.getAttribute('data-key').slice(0,2)+e.getAttribute('data-key').slice(-1))); +const F=(i)=>'f'+i.toString(16).padStart(63,'0'); +try { + await page.setViewport({width:390,height:844,isMobile:true,hasTouch:true}); + await page.emulateMediaFeatures([{name:'prefers-reduced-motion',value:'reduce'}]); + await page.goto(server.url.href); + await page.waitForSelector('.note-post',{timeout:5000}); + const reading='6'.padStart(64,'0'); + await page.evaluate((id)=>{const r=document.querySelector('.notes-feed > [data-key="'+id+'"]');window.scrollTo(0,scrollY+r.getBoundingClientRect().top-100);},reading); + await sleep(300); + const before=await top(reading); + await page.evaluate(()=>test.arrive(3)); await sleep(300); + assert(Math.abs((await top(reading))-before)<=2,'the row being read stays put when posts arrive above'); + assert.equal(await page.$eval('.feed-new-pill .n',e=>e.textContent),'3'); + assert.deepEqual((await fresh()).length,3,'all three new posts wear the tint'); + await page.tap('.feed-new-pill'); await sleep(400); + const oldest=await top(F(2)); + assert(oldest>=0&&oldest<=20,'pill lands on the oldest new post, not the top: '+oldest); + assert(await page.evaluate(()=>scrollY)>0,'not scrolled all the way to the top'); + assert(!(await page.$('.feed-new-pill')),'pill is gone after the tap'); + await page.screenshot({path:'/tmp/feed-new-posts.png'}); + await sleep(2600); + assert(!(await page.$('.notes-feed > .row.note-fresh[data-key="'+F(2)+'"]')),'the post you looked at loses its tint'); + assert(await page.$('.notes-feed > .row.note-fresh[data-key="'+F(0)+'"]'),'the newest, still offscreen above, keeps its tint'); + await page.evaluate(()=>window.scrollTo(0,0)); await sleep(2600); + assert.deepEqual(await fresh(),[],'back at the top, the rest fade once seen'); + assert.deepEqual(errors,[]); + console.log('✓ New posts: pill lands on the oldest new post; tints stay until each post has been in view ~2s; the reading row stays put'); +} finally { await page.close(); await browser.close(); server.stop(true); } -- cgit v1.2.3 From 2bbff9ac91bf310f63216523cb418474ca64db64 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 28 Sep 2026 22:25:57 -0700 Subject: A reloaded thread paints whole, quoted posts included, with no spinner. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thread cache kept the replies but not the posts quoted inside them, so every reload showed "Fetching note…" until a relay answered. Quotes (of any kind — the one that showed it was a poll) are now saved with the thread and restored before the first paint. Co-Authored-By: Claude Opus 5.5 --- src/features/messages.js | 20 +++++++++++++++++++- src/thread-cache.js | 12 +++++++++--- tools/thread-cache-test.js | 8 +++++++- 3 files changed, 35 insertions(+), 5 deletions(-) (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index 8c8bb15..bf3c9db 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -5310,6 +5310,9 @@ export function messagesFeature(ctx) { const evs = await queryOn(relays, { ids: [ref.id] }, 4500).catch(() => []); c.ev = (evs || [])[0] || c.ev || null; c.status = c.ev ? 'ready' : 'missing'; + // the open thread quotes it: keep it with the thread for the next reload + const th = c.ev && ui.noteThread && threadCache.get(ui.noteThread.rootId); + if (th && th.root) persistThread(th); scheduleRepaint(); })(); return c; @@ -6525,7 +6528,21 @@ export function messagesFeature(ctx) { const p = profiles.get(ev.pubkey); if (p && (p.name || p.picture)) faces[ev.pubkey] = p; } - threadStore.save(c, c.focusId, faces, c.counts); + // quoted posts inside the thread ride along, so a reload paints them + // instead of a "Fetching note…" spinner + const quotes = []; + for (const ev of [c.root, ...c.replies]) { + for (const m of (ev.content || '').matchAll(/nostr:((?:note|nevent)1[a-z0-9]+)/gi)) { + const ref = parseNostrRef(m[1].toLowerCase()); + const q = ref && ref.type === 'event' && quoted.get(ref.id); + if (q && q.ev) { + quotes.push(q.ev); + const p = profiles.get(q.ev.pubkey); + if (p && (p.name || p.picture)) faces[q.ev.pubkey] = p; + } + } + } + threadStore.save(c, c.focusId, faces, c.counts, quotes); }; function persistThreadProfile(pk) { const c = ui.noteThread && threadCache.get(ui.noteThread.rootId); @@ -6597,6 +6614,7 @@ export function messagesFeature(ctx) { } } } + for (const q of stored?.quotes || []) if (!quoted.get(q.id)?.ev) quoted.set(q.id, { status: 'ready', ev: q, at: Date.now() }); const rootId = stored?.rootId || requestedRootId; c = { status: stored ? 'ready' : 'loading', rootId, focusId: seed.id, root: stored?.root || (seed.id === rootId ? seed : null), replies: stored?.replies || [], counts: stored?.counts || {} }; diff --git a/src/thread-cache.js b/src/thread-cache.js index e11660e..0606f12 100644 --- a/src/thread-cache.js +++ b/src/thread-cache.js @@ -4,12 +4,15 @@ const KEY = 'btc-wallet-note-threads'; const MAX_THREADS = 12; const MAX_EVENTS = 160; +const MAX_QUOTES = 24; // posts quoted inside the thread, so a reload paints them too const MAX_BYTES = 1_000_000; // JSON code units; at most about 2 MB in storage const validNote = (e) => e?.kind === 1 && typeof e.id === 'string' && /^[0-9a-f]{64}$/.test(e.id) && typeof e.pubkey === 'string' && /^[0-9a-f]{64}$/.test(e.pubkey) && typeof e.content === 'string' && Number.isFinite(e.created_at) && Array.isArray(e.tags) && e.tags.every((t) => Array.isArray(t) && t.every((v) => typeof v === 'string')); +// A quote can be any kind of event (a poll, an article…), not only a note. +const validQuote = (e) => Number.isSafeInteger(e?.kind) && e.kind >= 0 && validNote({ ...e, kind: 1 }); const savedNote = ({ id, pubkey, kind, created_at, content, tags }) => ({ id, pubkey, kind, created_at, content, tags }); const savedProfile = (p) => { if (!p || typeof p !== 'object') return null; @@ -46,7 +49,7 @@ export function createThreadStore(storage) { find(id) { return read().find((c) => c.rootId === id || c.replies.some((e) => e.id === id)) || null; }, - save(thread, focusId, profiles = {}, counts = {}) { + save(thread, focusId, profiles = {}, counts = {}, quotes = []) { if (!validNote(thread.root)) return; const events = new Map([thread.root, ...thread.replies.filter(validNote)].map((e) => [e.id, e])); const keep = new Map([[thread.root.id, thread.root]]); @@ -65,7 +68,9 @@ export function createThreadStore(storage) { visit(e.id); } const previous = read().find((c) => c.rootId === thread.root.id); - const authors = [...new Set([...keep.values()].map((e) => e.pubkey))].slice(0, 24); + const quoteMap = new Map([...(previous?.quotes || []), ...quotes].filter(validQuote).map((e) => [e.id, e])); + const kept = [...quoteMap.values()].slice(-MAX_QUOTES); + const authors = [...new Set([...keep.values(), ...kept].map((e) => e.pubkey))].slice(0, 24 + MAX_QUOTES); const faces = {}; for (const pk of authors) { const fresh = savedProfile(profiles[pk]); @@ -83,7 +88,8 @@ export function createThreadStore(storage) { } const entry = { rootId: thread.root.id, root: savedNote(thread.root), profiles: faces, counts: tallies, - replies: [...keep.values()].filter((e) => e.id !== thread.root.id).map(savedNote) }; + replies: [...keep.values()].filter((e) => e.id !== thread.root.id).map(savedNote), + quotes: kept.map(savedNote) }; const entries = [entry, ...read().filter((c) => c.rootId !== entry.rootId)].slice(0, MAX_THREADS); let json = JSON.stringify(entries); while (json.length > MAX_BYTES && entries.length > 1) { entries.pop(); json = JSON.stringify(entries); } diff --git a/tools/thread-cache-test.js b/tools/thread-cache-test.js index 485e874..38da691 100644 --- a/tools/thread-cache-test.js +++ b/tools/thread-cache-test.js @@ -19,7 +19,7 @@ check('a fresh store restores full content and tags by any viewed event', () => const long = { ...reply, content: 'x'.repeat(5000), tags: [...reply.tags, ['emoji', 'wave', 'https://example.com/wave.png']] }; store.save({ root, replies: [parent, long] }, long.id); const restored = createThreadStore(storage).find(long.id); - assert.deepEqual(restored, { rootId: root.id, root, profiles: {}, counts: {}, replies: [long, parent] }); + assert.deepEqual(restored, { rootId: root.id, root, profiles: {}, counts: {}, replies: [long, parent], quotes: [] }); assert.equal(store.find(parent.id).root.id, root.id); }); check('thread authors keep their names and local avatar thumbnails across reloads', () => { @@ -36,6 +36,12 @@ check('thread action totals are present before relay refresh and survive a conte store.save({ root, replies: [parent, reply] }, reply.id); assert.deepEqual(createThreadStore(storage).find(reply.id).counts[reply.id], { likes: 29, boosts: 7, sats: 210 }); }); +check('posts quoted inside the thread are restored with it, and a later save keeps them', () => { + const q = note(77), poll = { ...note(78), kind: 1068 }; // a quote can be any kind + store.save({ root, replies: [parent, reply] }, reply.id, {}, {}, [q, poll]); + store.save({ root, replies: [parent, reply] }, reply.id); // a relay refresh with no quotes resolved yet + assert.deepEqual(createThreadStore(storage).find(reply.id).quotes, [q, poll]); +}); check('a large thread retains the selected reply and its ancestors', () => { const siblings = Array.from({ length: 200 }, (_, i) => note(1000 + i, [['e', root.id, '', 'root']])); store.save({ root, replies: [...siblings, parent, reply] }, reply.id); -- cgit v1.2.3 From 5676ffff812e2e1c3459d52128883b7494d5ebff Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 28 Sep 2026 22:41:01 -0700 Subject: A quoted note that was slow to arrive turns into its card instead of staying a "view note" link. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A feed row keeps its first look, and a quote that missed the readiness gate froze as a link — with nothing asking for the note again. The frozen row now keeps asking (the existing 30s retry) and takes the card once the note and its own avatar/pictures are warm. quote-card-test: reach the feed through the bottom nav, seed the author's profile, and --slow drops the first ask for the quote to cover this. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp --- src/features/messages.js | 25 +++++++++++++++++++++---- tools/quote-card-test.js | 23 ++++++++++++++++++++--- 2 files changed, 41 insertions(+), 7 deletions(-) (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index bf3c9db..701b131 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -5318,15 +5318,32 @@ export function messagesFeature(ctx) { return c; } + // A feed row that froze before its quote arrived showed a link — and + // nothing asked for the note again. Keep asking, and once the note and its + // own faces/pictures are in, the row takes the card. + function quoteWarm(ref) { + const c = quotedNote(ref); + if (c.ev && !c.warm && !c.warming) { + c.warming = noteReady(c.ev, Date.now() + READY_MS, 1).catch(() => {}) + .then(() => { c.warm = true; c.warming = null; scheduleRepaint(); }); + } + return c; + } + // The card. Deliberately not a noteRow: a quote is context, not another // post to act on — no reply, no boost, no zap of its own. Tapping it opens // the note properly, which is where those live. function quoteCard(ref, depth) { if (feedPaint && !feedPaint.quotes.has(ref.id)) feedPaint.quotes.set(ref.id, { ...quoted.get(ref.id) }); - const c = feedPaint ? feedPaint.quotes.get(ref.id) : quotedNote(ref); - if (feedPaint && !c?.ev) return h('a', { - href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); openNoteRef(ref); }, - }, t('noteRefLink')); + let c = feedPaint ? feedPaint.quotes.get(ref.id) : quotedNote(ref); + if (feedPaint && !c?.ev) { + const live = quoteWarm(ref); + if (!(live.ev && live.warm)) return h('a', { + href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); openNoteRef(ref); }, + }, t('noteRefLink')); + c = { ...live }; + feedPaint.quotes.set(ref.id, c); + } if (c.status === 'loading') { return h('div', { class: 'quote-card quote-loading' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small faint' }, t('noteRefLoading'))); diff --git a/tools/quote-card-test.js b/tools/quote-card-test.js index 27a1b22..91edf3b 100644 --- a/tools/quote-card-test.js +++ b/tools/quote-card-test.js @@ -30,6 +30,18 @@ const html = await buildHtml({ minify: true, pwa: false }); const server = Bun.serve({ port: 5269, 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(); +// --slow: the relays miss the quoted note the first time it is asked for +// (ours was down for an evening once), so the row freezes with the fallback +// link; the next ask, half a minute on, finds it. +const SLOW_MS = 32000; +await page.evaluateOnNewDocument((id) => { + const send = WebSocket.prototype.send; + const t0 = Date.now(); + WebSocket.prototype.send = function (d) { + if (localStorage.getItem('__slowQuote') && typeof d === 'string' && d.includes(id) && d.startsWith('["REQ"') && Date.now() - t0 < 20000) return; + return send.call(this, d); + }; +}, target.id); 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; }; try { @@ -52,13 +64,18 @@ try { localStorage.setItem(k + ':feedNotes', JSON.stringify([ { id: 'd'.repeat(64), pubkey: pk, kind: 1, created_at: Math.floor(Date.now()/1000), content: 'what he said nostr:' + ref, tags: [] }, ])); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Quoter', t: Date.now() } })); }, [base, A, nevent]); + if (process.argv.includes('--slow')) await page.evaluate(() => localStorage.setItem('__slowQuote', '1')); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); - await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); - await sleep(1000); - await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => /feed/i.test(n.textContent)); if (e) e.click(); }); + await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click()); await sleep(7000); + if (process.argv.includes('--slow')) { + for (let i = 0; i < 40 && !(await page.evaluate(() => document.body.innerText.includes('view note'))); i++) await sleep(250); + check('a late quote first paints as a link', await page.evaluate(() => document.body.innerText.includes('view note'))); + await sleep(SLOW_MS + 4000); + } const card = await page.evaluate(() => { const q = document.querySelector('.quote-card'); -- cgit v1.2.3 From 06df23884d69bb2f46a6deeb9b05722d5ba45b53 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 28 Sep 2026 22:48:26 -0700 Subject: A face tapped in the feed opens that person's profile at its top; Back returns to the same place in the feed. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The profile kept the feed's scroll offset, so the reader landed deep in the person's posts — which read as a thread opening. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp --- src/features/messages.js | 14 +++++++++ tools/avatar-profile-test.js | 73 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 tools/avatar-profile-test.js (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index 701b131..dd31f46 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -3021,7 +3021,20 @@ export function messagesFeature(ctx) { } } + // A profile opens at its top: keeping the feed's scroll offset landed the + // reader deep in that person's posts, which read as a thread. Back (a + // popstate) puts them where they were on the page they left. + const profReturn = []; // [{ pk, y }] per profile opened, newest last + window.addEventListener('popstate', () => { + const top = profReturn[profReturn.length - 1]; + if (!top || ui.profilePk === top.pk) return; + profReturn.pop(); + requestAnimationFrame(() => { try { window.scrollTo(0, top.y); } catch {} }); + }); function openProfile(pk) { + let y = 0; try { y = window.scrollY || 0; } catch {} + if (ui.profilePk !== pk) profReturn.push({ pk, y }); + if (profReturn.length > 20) profReturn.shift(); ui.profilePk = pk; // Opened from inside a thread (an author's avatar/name), the profile // stacks ON TOP of it — back returns to the conversation. The screen @@ -3030,6 +3043,7 @@ export function messagesFeature(ctx) { ui.profOverThread = !!ui.noteThread; ui.profEdit = null; ui.profEditFilled = false; ui.logoutConfirm = null; ui.profCompose = null; render(); + try { window.scrollTo(0, 0); } catch {} fetchFullProfile(pk); notesFor(pk); } diff --git a/tools/avatar-profile-test.js b/tools/avatar-profile-test.js new file mode 100644 index 0000000..c91ad26 --- /dev/null +++ b/tools/avatar-profile-test.js @@ -0,0 +1,73 @@ +// Tapping a face in the feed opens that person's profile AT ITS TOP — not +// the thread the row would open, and not the profile scrolled down to +// wherever the reader was in the feed. Back returns to the same place. +// +// Run: bun tools/avatar-profile-test.js +import puppeteer from 'puppeteer-core'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { cacheKeyFor } from '../src/wallet.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 html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5271, 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(); +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; }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5271/', { 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 A = 'a'.repeat(63) + '9', B = 'b'.repeat(63) + '7'; + await page.evaluate(([k, a, b]) => { + const now = Math.floor(Date.now() / 1000); + const ns = Array.from({ length: 30 }, (_, i) => ({ + id: (i + 16).toString(16).padStart(64, 'c'), pubkey: i % 2 ? b : a, kind: 1, created_at: now - i * 60, + content: 'post number ' + i + '\n\n' + 'a few lines so rows have height. '.repeat(4), tags: [], + })); + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', a], ['p', b]], c: '', at: now })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [a]: { name: 'Alice Poster', t: Date.now() }, [b]: { name: 'Bob Poster', about: 'bob bio here', t: Date.now() } })); + }, [base, A, B]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click()); + await waitText('post number 15', 15000); + await sleep(1500); + + // scroll down to a row well into the feed, then tap its face + await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed .note-avatar')].find((n) => n.closest('[data-key]')?.innerText.includes('post number 13')); r.scrollIntoView({ block: 'center' }); }); + await sleep(700); + const feedY = await page.evaluate(() => window.scrollY); + const pt = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed .note-avatar')].find((n) => n.closest('[data-key]')?.innerText.includes('post number 13')).getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); + await page.touchscreen.tap(pt.x, pt.y); + await sleep(1200); + const after = await page.evaluate(() => ({ y: window.scrollY, text: document.body.innerText.slice(0, 400), thread: !!document.querySelector('.note-thread, .thread-page') })); + if (process.env.DBG) console.log(feedY, JSON.stringify(after)); + check('the tap opens the profile', /Bob Poster/.test(after.text) && /bob bio here/.test(after.text), after.text.replace(/\s+/g, ' ').slice(0, 80)); + check('...at its top', after.y < 5, 'scrollY ' + after.y + ' (feed was at ' + feedY + ')'); + + await page.goBack().catch(() => {}); + await sleep(1200); + let back = await page.evaluate(() => ({ y: window.scrollY, feed: !!document.querySelector('.notes-feed') && document.body.innerText.includes('post number 13') })); + if (!back.feed) { + await page.evaluate(() => { const b = document.querySelector('.back, [aria-label="Back"]'); if (b) b.click(); }); + await sleep(1200); + back = await page.evaluate(() => ({ y: window.scrollY, feed: !!document.querySelector('.notes-feed') })); + } + check('back returns to the feed where the reader was', back.feed && Math.abs(back.y - feedY) < 60, 'scrollY ' + back.y + ' vs ' + feedY); +} 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 86d5609a188ce4dae248d1b56c4563bae299af31 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 28 Sep 2026 22:53:37 -0700 Subject: A thread that starts from a poll (or any non-kind-1 post) is cached too, so a reload paints its replies at once. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thread store accepted only kind 1 and silently skipped saving a thread rooted in a kind 1068 poll — every refresh then waited on the relays behind a spinner. Also guard the profile Back listener for window-less tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp --- src/features/messages.js | 2 +- src/thread-cache.js | 7 +++++-- tools/thread-cache-test.js | 8 ++++++++ 3 files changed, 14 insertions(+), 3 deletions(-) (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index dd31f46..2a3b400 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -3025,7 +3025,7 @@ export function messagesFeature(ctx) { // reader deep in that person's posts, which read as a thread. Back (a // popstate) puts them where they were on the page they left. const profReturn = []; // [{ pk, y }] per profile opened, newest last - window.addEventListener('popstate', () => { + if (typeof window !== 'undefined') window.addEventListener('popstate', () => { const top = profReturn[profReturn.length - 1]; if (!top || ui.profilePk === top.pk) return; profReturn.pop(); diff --git a/src/thread-cache.js b/src/thread-cache.js index 0606f12..fca64e6 100644 --- a/src/thread-cache.js +++ b/src/thread-cache.js @@ -6,13 +6,16 @@ const MAX_THREADS = 12; const MAX_EVENTS = 160; const MAX_QUOTES = 24; // posts quoted inside the thread, so a reload paints them too const MAX_BYTES = 1_000_000; // JSON code units; at most about 2 MB in storage -const validNote = (e) => e?.kind === 1 && typeof e.id === 'string' +// Any kind: a thread can hang off a poll (1068), a picture (20), a comment +// (1111)… — requiring kind 1 left those threads uncached, so every reload +// sat on a spinner while the relays answered. +const validNote = (e) => Number.isSafeInteger(e?.kind) && e.kind >= 0 && typeof e.id === 'string' && /^[0-9a-f]{64}$/.test(e.id) && typeof e.pubkey === 'string' && /^[0-9a-f]{64}$/.test(e.pubkey) && typeof e.content === 'string' && Number.isFinite(e.created_at) && Array.isArray(e.tags) && e.tags.every((t) => Array.isArray(t) && t.every((v) => typeof v === 'string')); // A quote can be any kind of event (a poll, an article…), not only a note. -const validQuote = (e) => Number.isSafeInteger(e?.kind) && e.kind >= 0 && validNote({ ...e, kind: 1 }); +const validQuote = validNote; const savedNote = ({ id, pubkey, kind, created_at, content, tags }) => ({ id, pubkey, kind, created_at, content, tags }); const savedProfile = (p) => { if (!p || typeof p !== 'object') return null; diff --git a/tools/thread-cache-test.js b/tools/thread-cache-test.js index 38da691..e8b0ccf 100644 --- a/tools/thread-cache-test.js +++ b/tools/thread-cache-test.js @@ -22,6 +22,14 @@ check('a fresh store restores full content and tags by any viewed event', () => assert.deepEqual(restored, { rootId: root.id, root, profiles: {}, counts: {}, replies: [long, parent], quotes: [] }); assert.equal(store.find(parent.id).root.id, root.id); }); +check('a thread rooted in a poll (kind 1068) is kept too', () => { + const poll = { ...note(40), kind: 1068, tags: [['option', '0', 'Yes!'], ['option', '1', 'No!']] }; + const answer = note(41, [['e', poll.id, '', 'root']]); + store.save({ root: poll, replies: [answer] }, poll.id); + const restored = createThreadStore(storage).find(poll.id); + assert.equal(restored?.root.kind, 1068); + assert.deepEqual(restored.replies, [answer]); +}); check('thread authors keep their names and local avatar thumbnails across reloads', () => { const thumb = 'data:image/webp;base64,AAAA'; const face = { name: 'Nostrich', picture: 'https://example.com/face.webp', eventAt: 50, -- 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/features') 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 73ab54a004113b32a9a12661e7e7eb9151999ee3 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 28 Sep 2026 23:25:10 -0700 Subject: Someone's timeline gets a labelled feed chip instead of a blank black pill. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp --- src/features/messages.js | 4 +++- tools/avatar-profile-test.js | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index c6a99b5..452377e 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -7680,7 +7680,9 @@ export function messagesFeature(ctx) { ...items.map((f) => h('button', { class: 'feed-chip' + (f.id === curFeedId ? ' on' : ''), type: 'button', 'data-key': 'chip:' + f.id, onClick: () => switchFeed(f.id), - }, f.name)), + // 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))), 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 diff --git a/tools/avatar-profile-test.js b/tools/avatar-profile-test.js index c91ad26..0f59e10 100644 --- a/tools/avatar-profile-test.js +++ b/tools/avatar-profile-test.js @@ -68,6 +68,14 @@ try { back = await page.evaluate(() => ({ y: window.scrollY, feed: !!document.querySelector('.notes-feed') })); } check('back returns to the feed where the reader was', back.feed && Math.abs(back.y - feedY) < 60, 'scrollY ' + back.y + ' vs ' + feedY); + + // "Their timeline" from the profile: its chip names it (it was a blank pill) + await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed .note-avatar')].find((n) => n.closest('[data-key]')?.innerText.includes('Bob Poster')); r.click(); }); + await sleep(1000); + await click('their timeline'); + 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)); } 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 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/features') 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 b9c04f573567d69d94fb120aa8feed89cf75585c Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 29 Sep 2026 07:48:59 -0700 Subject: Cache notification note content locally --- src/features/messages.js | 73 +++++++++++++++++++++-- tools/notif-note-cache-test.js | 128 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 tools/notif-note-cache-test.js (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index 2cf799b..7505dbf 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -5346,6 +5346,7 @@ export function messagesFeature(ctx) { const evs = await queryOn(relays, { ids: [ref.id] }, 4500).catch(() => []); c.ev = (evs || [])[0] || c.ev || null; c.status = c.ev ? 'ready' : 'missing'; + if (c.ev) cacheNotifNotes([c.ev]); // the open thread quotes it: keep it with the thread for the next reload const th = c.ev && ui.noteThread && threadCache.get(ui.noteThread.rootId); if (th && th.root) persistThread(th); @@ -8983,6 +8984,7 @@ export function messagesFeature(ctx) { const NOTIF_KEEP = 150; let notif = null; // { status, items } let notifAt = 0, notifUnsub = null; + const notifNoteAsking = new Set(); const notifSeen = () => st().notifSeen || 0; // Notifications are about the IDENTITY, not the wallet: the stored list // remembers whose it is, and another identity on the same wallet starts @@ -8993,7 +8995,17 @@ export function messagesFeature(ctx) { const s = st(); const stored = (!s.notifsPk || s.notifsPk === identityPk()) ? (s.notifs || []) : []; notif = { status: stored.length ? 'ready' : 'loading', items: stored }; + // Reply events and the notes reactions/zaps point at ride beside their + // notification rows. Seed the in-memory note caches synchronously, so + // opening Notifications after a reload never paints "Fetching note…". + const noteCache = s.notifNotesPk === identityPk() ? (s.notifNoteCache || {}) : {}; + for (const note of Object.values(noteCache)) { + if (!note || !note.id) continue; + notifNotes.set(note.id, note); + quoted.set(note.id, { status: 'ready', ev: note, at: Date.now() }); + } warmNotifFaces(stored); + warmNotifNotes(stored); refreshNotifs(); } return notif; @@ -9011,6 +9023,58 @@ export function messagesFeature(ctx) { const deadline = Date.now() + READY_MS; for (const pk of pks) warmAvatar(pk, deadline).catch(() => {}); } + // Keep only what rendering and opening a thread need. Signatures and relay + // metadata make this cache unnecessarily large; the relays remain the + // source of truth whenever the live notification refresh runs. + const savedNotifNote = (ev) => ev && ev.id ? ({ + id: ev.id, pubkey: ev.pubkey, kind: ev.kind, created_at: ev.created_at, + content: ev.content || '', tags: ev.tags || [], + }) : null; + function persistNotifs() { + if (!notif) return; + const wanted = new Set(); + for (const x of notif.items) { + const id = x.what === 'reply' || x.what === 'mention' ? x.id : x.target; + if (id) wanted.add(id); + } + const notes = {}; + for (const id of wanted) if (notifNotes.has(id)) notes[id] = savedNotifNote(notifNotes.get(id)); + const s2 = st(); + s2.notifs = notif.items; s2.notifsPk = identityPk(); + s2.notifNoteCache = notes; s2.notifNotesPk = identityPk(); + save(s2); + } + function cacheNotifNotes(evs) { + if (!notif || !(evs || []).length) return false; + const byId = new Map((evs || []).filter((ev) => ev && ev.id).map((ev) => [ev.id, savedNotifNote(ev)])); + let changed = false; + const wanted = new Set(); + for (const x of notif.items) wanted.add(x.what === 'reply' || x.what === 'mention' ? x.id : x.target); + for (const [id, note] of byId) { + if (!wanted.has(id) || notifNotes.has(id)) continue; + notifNotes.set(id, note); + quoted.set(id, { status: 'ready', ev: note, at: Date.now() }); + changed = true; + } + if (changed) persistNotifs(); + return changed; + } + // Old notification rows did not carry their note. Upgrade them in one + // bounded batch while the bell/chat home is visible, before the user opens + // the page. New rows take the same path as soon as they arrive. + function warmNotifNotes(items) { + const ids = [...new Set((items || []) + .filter((x) => x && x.target && x.what !== 'reply' && x.what !== 'mention' && !notifNotes.has(x.target)) + .map((x) => x.target).filter((id) => !notifNoteAsking.has(id)))].slice(0, NOTIF_KEEP); + if (!ids.length) return; + for (const id of ids) notifNoteAsking.add(id); + (async () => { + const got = []; + for (let i = 0; i < ids.length; i += 60) + got.push(...await queryOn(zapRelays(), { ids: ids.slice(i, i + 60) }, 4500).catch(() => [])); + if (cacheNotifNotes(got)) scheduleRepaint(); + })().finally(() => { for (const id of ids) notifNoteAsking.delete(id); }); + } // What an event says happened, or null if it isn't about you after all. function notifItem(ev) { const my = myPubkeys(); @@ -9057,11 +9121,12 @@ export function messagesFeature(ctx) { kept.push(x); } c.items = kept.slice(0, NOTIF_KEEP); - // the rows already say who; a reply row also wants its reader-facing text, - // which it carries — the events themselves are not kept - const s2 = st(); s2.notifs = c.items; s2.notifsPk = identityPk(); save(s2); - // a reply is a note we can open straight away; keep it in hand + // Keep the small notification rows and one deduplicated copy of each note + // they show. A reply is also a note we can open straight away. for (const ev of evs) if (ev.kind === 1) notifNotes.set(ev.id, ev); + cacheNotifNotes(evs); + warmNotifNotes(c.items); + persistNotifs(); return true; } const notifNotes = new Map(); // reply id -> event, for opening the thread diff --git a/tools/notif-note-cache-test.js b/tools/notif-note-cache-test.js new file mode 100644 index 0000000..c0ceeca --- /dev/null +++ b/tools/notif-note-cache-test.js @@ -0,0 +1,128 @@ +// Referenced notification notes are fetched before the page opens, persisted, +// and available on the first paint after a reload. +// Run: bun tools/notif-note-cache-test.js +import puppeteer from 'puppeteer-core'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; +import { buildHtml } from '../build.js'; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +let ok = true; +const check = (name, pass, detail = '') => { + console.log(` ${pass ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`); + if (!pass) ok = false; +}; +const now = Math.floor(Date.now() / 1000); +const actorSk = generateSecretKey(); +const actorPk = getPublicKey(actorSk); +const noteSk = generateSecretKey(); +const noteText = 'This notification excerpt came from the local note cache.'; +const target = finalizeEvent({ kind: 1, created_at: now - 120, tags: [], content: noteText }, noteSk); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ + port: 5307, + fetch(req) { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); + }, +}); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.evaluateOnNewDocument((targetEvent) => { + window.__targetReqs = 0; + const Real = window.WebSocket; + window.WebSocket = function (...args) { + const ws = new Real(...args); + const send = ws.send.bind(ws); + ws.send = (data) => { + try { + const msg = JSON.parse(data); + if (msg[0] === 'REQ') { + const filter = msg[2] || {}; + const hit = (filter.ids || []).includes(targetEvent.id); + if (hit) window.__targetReqs++; + setTimeout(() => { + if (hit) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', msg[1], targetEvent]) })); + ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', msg[1]]) })); + }, 0); + return; + } + if (msg[0] === 'EVENT') return; + } catch {} + return send(data); + }; + return ws; + }; + window.WebSocket.prototype = Real.prototype; + Object.assign(window.WebSocket, Real); +}, target); + +const clickText = (text) => page.evaluate((needle) => { + const el = [...document.querySelectorAll('button')].find((node) => node.textContent.trim().toLowerCase().includes(needle)); + if (!el) return false; + el.click(); return true; +}, text); +const waitText = async (text, timeout = 20_000) => { + for (let elapsed = 0; elapsed < timeout; elapsed += 200) { + if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(text.toLowerCase())) return true; + await sleep(200); + } + return false; +}; +const openNotifications = () => page.evaluate(() => { + const el = [...document.querySelectorAll('button')].find((node) => /notifications/i.test(node.getAttribute('aria-label') || '')); + if (!el) return false; + el.click(); return true; +}); + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5307/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await clickText('create a new wallet'); + await page.waitForSelector('.words .w .t'); + const mnemonic = (await page.$$eval('.words .w .t', (nodes) => nodes.map((node) => node.textContent.trim()))).join(' '); + await clickText('skip verification'); + await waitText('receive'); + const base = cacheKeyFor(mnemonic + '\n'); + const me = getPublicKey(privateKeyFromSeedWords(mnemonic)); + const item = { id: 'a'.repeat(64), what: 'react', actor: actorPk, target: target.id, hint: [], emoji: '❤️', ts: now - 30 }; + await page.evaluate(([key, owner, notification]) => { + const state = JSON.parse(localStorage.getItem(key + ':messages') || '{}'); + state.notifs = [notification]; state.notifsPk = owner; + localStorage.setItem(key + ':messages', JSON.stringify(state)); + }, [base, me, item]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive'); + + let cached = false; + for (let i = 0; i < 75; i++) { + cached = await page.evaluate(([key, id]) => { + const state = JSON.parse(localStorage.getItem(key + ':messages') || '{}'); + return state.notifNoteCache && state.notifNoteCache[id]?.content; + }, [base, target.id]); + if (cached) break; + await sleep(200); + } + check('the referenced note is warmed and persisted', cached === noteText); + check('the relay was queried while the page was still closed', (await page.evaluate(() => window.__targetReqs)) > 0); + + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive'); + check('notifications opens from the bottom navigation', await openNotifications()); + await sleep(80); + const firstPaint = await page.evaluate(() => document.body.innerText); + check('the cached excerpt is present immediately', firstPaint.includes('This notification excerpt')); + check('there is no Fetching note placeholder', !firstPaint.includes('Fetching note')); + check('the persisted note avoids another target query', (await page.evaluate(() => window.__targetReqs)) === 0); +} finally { + await browser.close(); + server.stop(true); +} + +console.log(ok ? '\n✅ notification note cache works' : '\n❌ notification note cache 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/features') 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 6ee2e4e000c9513d09c98554ed0162a97a4f7824 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 29 Sep 2026 08:36:41 -0700 Subject: Discard canceled post drafts --- src/features/messages.js | 50 +++++++++++++++++++++++++++++++++++------------ tools/post-cancel-test.js | 17 ++++++++++++++++ 2 files changed, 55 insertions(+), 12 deletions(-) create mode 100644 tools/post-cancel-test.js (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index fb88a56..10773c8 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1336,6 +1336,16 @@ export function messagesFeature(ctx) { save(s); }, 800); } + // Cancel means discard, including across an immediate reload. Typing can + // use the debounce above; an explicit destructive action is persisted now. + function discardDraft(key) { + sessionDrafts.set(key, ''); + clearTimeout(draftPersist); draftPersist = 0; + const s = st(); + s.drafts ||= {}; + for (const [k, v] of sessionDrafts) { if (v) s.drafts[k] = v; else delete s.drafts[k]; } + save(s); + } // The morph never rewrites a focused field's value (it would fight the user // mid-keystroke), and the composer is focused at the moment you send — so @@ -7586,6 +7596,7 @@ export function messagesFeature(ctx) { // client reads media — with a NIP-92 imeta tag published beside it for the // ones that would rather have the metadata than sniff the URL. const composeText = () => (ui.profCompose == null ? draftFor(POST_DRAFT) : ui.profCompose || ''); + let postAttachGeneration = 0; // Upload, then hand the URL to whichever draft asked for it. async function attachTo(file, place) { const upload = ctx.uploadMedia || ctx.uploadImage; @@ -7593,20 +7604,35 @@ export function messagesFeature(ctx) { ui.postUploading = true; render(); try { const url = await upload(file); - (ui.postMedia ||= []).push({ url, m: file.type || '' }); - place(url); + // A post canceled while its upload was in flight must stay canceled. + // `false` lets that composer reject the late result without affecting + // the reply composer, which shares this helper. + if (place(url) !== false) (ui.postMedia ||= []).push({ url, m: file.type || '' }); } catch (e) { toast(e.message || String(e)); } ui.postUploading = false; render(); } - const attachMedia = (file) => attachTo(file, (url) => { - const cur = composeText().replace(/\s+$/, ''); - const next = (cur ? cur + '\n' : '') + url; - ui.profCompose = next; - setDraft(POST_DRAFT, next); - // you just attached a picture; showing it IS the answer to the question - // attaching one raises - ui.postPreview = true; - }); + const attachMedia = (file) => { + const generation = postAttachGeneration; + return attachTo(file, (url) => { + if (generation !== postAttachGeneration) return false; + const cur = composeText().replace(/\s+$/, ''); + const next = (cur ? cur + '\n' : '') + url; + ui.profCompose = next; + setDraft(POST_DRAFT, next); + // you just attached a picture; showing it IS the answer to the question + // attaching one raises + ui.postPreview = true; + return true; + }); + }; + function cancelPost() { + postAttachGeneration++; + ui.profCompose = null; + ui.postMedia = []; + ui.postPreview = false; + discardDraft(POST_DRAFT); + render(); + } const CLIP = ''; const EYE = ''; @@ -7677,7 +7703,7 @@ 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: () => { ui.profCompose = null; render(); } }, t('cancel')))); + h('button', { class: 'btn-ghost', onClick: cancelPost }, t('cancel')))); } // ---- the feed view -------------------------------------------------------- diff --git a/tools/post-cancel-test.js b/tools/post-cancel-test.js new file mode 100644 index 0000000..5527bf2 --- /dev/null +++ b/tools/post-cancel-test.js @@ -0,0 +1,17 @@ +// Canceling a new post closes the composer and discards every part of its +// saved state, including a late attachment result. +import assert from 'node:assert/strict'; + +const source = await Bun.file('src/features/messages.js').text(); +const start = source.indexOf('function cancelPost()'); +const end = source.indexOf('\n }', start); +const cancel = source.slice(start, end); +assert(start > 0, 'post cancel handler exists'); +assert(cancel.includes('postAttachGeneration++'), 'late uploads are invalidated'); +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'); + +console.log('✓ Cancel closes the new-post form and removes its complete saved draft'); -- 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/features') 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 4164d22809e49f1a5c55571f4f37eed8c2a70ba7 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 29 Sep 2026 08:43:05 -0700 Subject: A community opens with its members' faces, not a beat of punks. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Faces (profile, then the picture decoded) are warmed when the pointer is over or presses a room's row, and while the chat list sits there; the tap waits for them, 1.5 s at most, before the room replaces the list. A face still loading in a room is the quiet circle, not a punk. room-faces-test (fresh wallet, real coinos community): 57 faces flipped punk → picture before, 0 after. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp --- src/features/messages.js | 47 +++++++++++++++++++++++++++++++++++++++-- tools/room-faces-test.js | 55 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 tools/room-faces-test.js (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index 89fe55a..38bc899 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -2907,7 +2907,7 @@ export function messagesFeature(ctx) { ? h('div', { class: cls + ' fallback' }, (p.name || npubOf(pk) || '??').slice(0, 2)) : p && p.loading && !p.picture ? h('div', { class: cls + ' fallback loading' }) - : p === null && ui.noteThread + : p === null && (ui.noteThread || (ui.chatOpen && ui.msgView === 'room')) ? h('div', { class: cls + ' fallback loading' }) : p === null ? fallbackAvatar(h, pk, null, cls) @@ -8627,6 +8627,9 @@ export function messagesFeature(ctx) { h('button', { class: 'btn-sm', onClick: () => createCommunity(ui.msgNewName || '') }, t('msgCreate')))); const allRooms = communities(); const shownRooms = ui.msgAllRooms ? allRooms : allRooms.slice(0, LIST_PREVIEW); + // the rooms on the list get their faces while it sits there, so even a + // tap with no hover before it finds them ready + setTimeout(() => { for (const jm of shownRooms) if (rooms.has(jm.community_id)) warmRoom(jm); }, 300); kids.push(h('div', { class: 'list' }, shownRooms.map((jm) => { const room = rooms.get(jm.community_id); const name = room?.folded?.metadata?.name || jm.name; @@ -8641,7 +8644,9 @@ export function messagesFeature(ctx) { const unread = room ? roomUnread(room) : false; return h('div', { class: 'item chat-thread-row' + (unread ? ' unread' : ''), - onClick: () => { ui.msgView = 'room'; ui.msgCommunity = jm.community_id; ui.msgChannel = null; ui.msgStick = true; render(); }, + onMouseenter: () => warmRoom(jm), + onPointerdown: () => warmRoom(jm), + onClick: () => openRoomWarm(jm, () => { ui.msgView = 'room'; ui.msgCommunity = jm.community_id; ui.msgChannel = null; ui.msgStick = true; }), }, h('div', { class: 'chat-avatar fallback' }, name.slice(0, 2)), h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, @@ -8692,6 +8697,44 @@ export function messagesFeature(ctx) { // ---- room --------------------------------------------------------------- + // ---- a room's faces, before it opens ------------------------------------- + // Opening a community painted everyone as a punk for a beat: their + // profiles weren't in memory yet. As SvelteKit preloads on hover, a + // pointer over (or pressing) a room's row starts the faces of its latest + // messages — profile, then the picture decoded — and the tap waits for + // them, a moment at most, before the room replaces the list. + const ROOM_READY_MS = 1500; + const roomWarm = new Map(); // cid -> { at, promise } + let roomOpenSeq = 0; + function roomFaces(jm) { + const room = ensureRoom(jm); + const ch = roomChannels(room)[0]; + if (!ch) return []; + // everyone the channel paints, newest speakers first + const msgs = [...(room.byChannel.get(ch.id)?.values() || [])] + .sort((a, b) => eventMs(b.rumor) - eventMs(a.rumor)); + return [...new Set(msgs.map((m) => m.author))].slice(0, 150); + } + function warmRoom(jm) { + const w = roomWarm.get(jm.community_id); + if (w && Date.now() - w.at < 30_000) return w.promise; + const deadline = Date.now() + 8000; + let promise; + try { promise = Promise.all(roomFaces(jm).map((pk) => warmAvatar(pk, deadline).catch(() => {}))); } + catch { promise = Promise.resolve(); } + roomWarm.set(jm.community_id, { at: Date.now(), promise }); + return promise; + } + async function openRoomWarm(jm, go) { + const seq = ++roomOpenSeq; + const from = ui.msgView; + await Promise.race([warmRoom(jm), new Promise((r) => setTimeout(r, ROOM_READY_MS))]); + // another tap, or the reader went elsewhere, while the faces loaded + if (seq !== roomOpenSeq || ui.msgView !== from || !ui.chatOpen) return; + go(); + render(); + } + function messageRows(room, chId) { const my = myPubkeys(); const msgs = [...(room.byChannel.get(chId)?.values() || [])] diff --git a/tools/room-faces-test.js b/tools/room-faces-test.js new file mode 100644 index 0000000..8c39e59 --- /dev/null +++ b/tools/room-faces-test.js @@ -0,0 +1,55 @@ +// Opening a community must not paint everyone as a punk and then swap in +// their real pictures: faces are warmed on hover/press (and while the list +// sits there) and the tap waits a moment for them. A fresh wallet has no +// profiles cached — the worst case — against the real coinos community. +// +// Run: bun tools/room-faces-test.js [--no-hover] +import puppeteer from 'puppeteer-core'; +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 html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5277, 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(); +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; }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5277/', { 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'); + await click('skip verification'); + await waitText('receive', 20000); + await page.evaluate(() => [...document.querySelectorAll('.app-bottom-nav .app-nav-button')][1].click()); + await waitText('members', 20000); + // let the community's history arrive (the list shows before it has) + await sleep(process.argv.includes('--no-hover') ? 1500 : 6000); + const face = () => [...document.querySelectorAll('.chat-log .chat-row > .chat-avatar:not(.spacer), .chat-log .chat-row > .hat-wrap > .chat-avatar')] + .map((a) => a.classList.contains('ava-img') ? 'pic' : a.querySelector('img.punk') ? 'punk' : a.classList.contains('loading') ? 'wait' : 'other'); + await page.evaluate((fs) => { + const face = new Function('return ' + fs)(); + const mo = new MutationObserver(() => { if (!window.__first && document.querySelector('.chat-log .chat-row')) { window.__first = face(); window.__firstAt = performance.now(); } }); + mo.observe(document.body, { childList: true, subtree: true }); + }, face.toString()); + const row = await page.evaluateHandle(() => [...document.querySelectorAll('.chat-thread-row')].find((r) => /members/.test(r.innerText))); + const t0 = await page.evaluate(() => performance.now()); + if (!process.argv.includes('--no-hover')) await row.hover(); + await row.click(); + for (let i = 0; i < 60 && !(await page.evaluate(() => !!window.__first)); i++) await sleep(100); + await sleep(6000); + const [first, at] = await page.evaluate(() => [window.__first, window.__firstAt]); + const last = await page.evaluate(new Function('return (' + face.toString() + ')()')); + const flips = (first || []).filter((f, i) => f === 'punk' && last[i] === 'pic').length; + console.log(' first paint after', Math.round(at - t0), 'ms:', JSON.stringify(tally(first)), ' settled:', JSON.stringify(tally(last))); + check('the room opens with its messages', (first || []).length > 0); + check('no face paints as a punk and then turns into a picture', flips === 0, flips + ' flipped'); + check('the tap waits no more than a moment', at - t0 < 2500, Math.round(at - t0) + 'ms'); +} finally { await browser.close(); server.stop(true); } +function tally(a) { const o = {}; for (const x of a || []) o[x] = (o[x] || 0) + 1; return o; } +console.log(ok ? '\n✅ a room opens with its faces' : '\n❌ failed'); +process.exit(ok ? 0 : 1); -- cgit v1.2.3 From d2b3c05995b0d54cfe586128fd2e958a563ac861 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 29 Sep 2026 09:03:13 -0700 Subject: Someone with no profile picture paints as their punk from the first frame after a reload, not a blank circle that turns into one. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Faceless answers were never persisted (they'd evict real faces from the 150-row profile blob), so every reload re-asked and showed the loading circle until the relays said "nothing". A small pubkey->time list now remembers them: no kind 0 on two asks running (a cold relay isn't enough), or a kind 0 naming nothing; finding a face takes them off it. room-faces-test --refresh: reload inside the coinos room, faces keyed by author — 2 changed (wait→punk) before, 0 after. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp --- src/features/messages.js | 36 ++++++++++++++++++++++++++++++++---- tools/room-faces-test.js | 30 ++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index 38bc899..a86d0a9 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -555,6 +555,29 @@ export function messagesFeature(ctx) { profilesWarmed = true; const cached = profilesDisk(); for (const [pk, p] of Object.entries(cached)) if (!profiles.has(pk)) profiles.set(pk, p); + // someone known to have no face paints their punk from the first frame, + // instead of a blank circle that turns into one when the relays answer + for (const [pk, t] of Object.entries(facelessDisk())) { + if (!profiles.has(pk)) profiles.set(pk, { name: null, picture: null, t, miss: 1 }); + } + } + // People with no face: no kind 0 on two asks running, or one naming + // nothing. Kept apart from the profile blob (whose 150 rows are for real + // faces) as a small pubkey -> time map; one found with a face leaves it. + const FACELESS_MAX = 1000; + let facelessBlob = null, facelessFlush = null; + const facelessDisk = () => (facelessBlob ||= wallet.loadFeatureState('profilesFaceless', {}) || {}); + function markFaceless(pk, on) { + const s = facelessDisk(); + if (on ? s[pk] : !s[pk]) return; + if (on) s[pk] = Date.now(); else delete s[pk]; + if (facelessFlush) return; + facelessFlush = setTimeout(() => { + facelessFlush = null; + const keys = Object.keys(s); + if (keys.length > FACELESS_MAX) for (const k of keys.sort((a, b) => s[a] - s[b]).slice(0, keys.length - FACELESS_MAX)) delete s[k]; + wallet.saveFeatureState('profilesFaceless', s); + }, 500); } // The persisted profile blob (names, faces, thumbnails) lives parsed in // memory and is written back once per burst: a batch of thirty profiles @@ -592,9 +615,10 @@ export function messagesFeature(ctx) { wallet.saveFeatureState('profiles', s); } function persistProfile(pk, p) { - // A faceless answer is not a fact worth writing down — persisting - // {name:null, picture:null} rows only evicts real faces from the cap and - // spreads a cold-relay miss across sessions. + // A faceless answer is not a fact worth writing down here — persisting + // {name:null, picture:null} rows only evicts real faces from the cap. + // (It goes on the faceless list instead.) + if (p && p.eventAt) markFaceless(pk, !p.name && !p.picture); if (!p || (!p.name && !p.picture)) return; const s = profilesDisk(); s[pk] = { name: p.name || null, picture: p.picture || null, nip05: p.nip05 || null, lud16: p.lud16 || null, @@ -927,7 +951,11 @@ export function messagesFeature(ctx) { for (const pk of pks) { if (found.has(pk)) continue; const prev = profiles.get(pk); - profiles.set(pk, { ...(prev || {}), t: Date.now(), miss: ((prev && prev.miss) || 0) + 1 }); + const miss = ((prev && prev.miss) || 0) + 1; + profiles.set(pk, { ...(prev || {}), t: Date.now(), miss }); + // twice running: not a cold relay, they have none (never someone + // whose face we do know) + if (miss >= 2 && !(prev && (prev.name || prev.picture))) markFaceless(pk, true); } scheduleRepaint(); } finally { diff --git a/tools/room-faces-test.js b/tools/room-faces-test.js index 8c39e59..706691b 100644 --- a/tools/room-faces-test.js +++ b/tools/room-faces-test.js @@ -31,6 +31,10 @@ try { await sleep(process.argv.includes('--no-hover') ? 1500 : 6000); const face = () => [...document.querySelectorAll('.chat-log .chat-row > .chat-avatar:not(.spacer), .chat-log .chat-row > .hat-wrap > .chat-avatar')] .map((a) => a.classList.contains('ava-img') ? 'pic' : a.querySelector('img.punk') ? 'punk' : a.classList.contains('loading') ? 'wait' : 'other'); + // name -> face state, per author (rows stream in, so positions shift) + const keyed = () => { const o = {}; for (const r of document.querySelectorAll('.chat-log .chat-row')) { + const a = r.querySelector(':scope > .chat-avatar:not(.spacer), :scope > .hat-wrap > .chat-avatar'); const n = r.querySelector('.chat-meta')?.firstElementChild?.textContent; + if (a && n) o[n] = a.classList.contains('ava-img') ? 'pic' : a.querySelector('img.punk') ? 'punk' : a.classList.contains('loading') ? 'wait' : 'other'; } return o; }; await page.evaluate((fs) => { const face = new Function('return ' + fs)(); const mo = new MutationObserver(() => { if (!window.__first && document.querySelector('.chat-log .chat-row')) { window.__first = face(); window.__firstAt = performance.now(); } }); @@ -49,6 +53,32 @@ try { check('the room opens with its messages', (first || []).length > 0); check('no face paints as a punk and then turns into a picture', flips === 0, flips + ' flipped'); check('the tap waits no more than a moment', at - t0 < 2500, Math.round(at - t0) + 'ms'); + + if (process.argv.includes('--refresh')) { + // a reload inside the room: every face as it will stay, from the first + // frame — a known-faceless member is their punk at once, not a blank + // circle that turns into one. (Wait out the second ask that confirms + // who has no profile.) + await sleep(25000); + await page.evaluateOnNewDocument((fs, ks) => { + const face = new Function('return ' + fs)(); + const keyed = new Function('return ' + ks)(); + const mo = new MutationObserver(() => { if (!window.__first && document.querySelector('.chat-log .chat-row')) { window.__first = face(); window.__firstBy = keyed(); } }); + addEventListener('DOMContentLoaded', () => mo.observe(document.body, { childList: true, subtree: true })); + }, face.toString(), keyed.toString()); + await page.reload({ waitUntil: 'domcontentloaded' }); + for (let i = 0; i < 100 && !(await page.evaluate(() => !!window.__first)); i++) await sleep(100); + await sleep(6000); + const f2 = await page.evaluate(() => window.__first); + const l2 = await page.evaluate(new Function('return (' + face.toString() + ')()')); + const byName = await page.evaluate(new Function('return (' + keyed.toString() + ')()')); + const firstBy = await page.evaluate(() => window.__firstBy); + const changedWho = Object.keys(firstBy || {}).filter((k) => byName[k] && firstBy[k] !== byName[k] && !(firstBy[k] === 'punk' && byName[k] === 'other')); + if (changedWho.length) console.log(' changed:', changedWho.map((k) => k + ' ' + firstBy[k] + '→' + byName[k]).join(', ')); + const changed = changedWho.length; + console.log(' after reload, first paint:', JSON.stringify(tally(f2)), ' settled:', JSON.stringify(tally(l2))); + check('after a reload in the room, no face changes once painted', !!f2 && changed === 0, changed + ' changed'); + } } finally { await browser.close(); server.stop(true); } function tally(a) { const o = {}; for (const x of a || []) o[x] = (o[x] || 0) + 1; return o; } console.log(ok ? '\n✅ a room opens with its faces' : '\n❌ failed'); -- cgit v1.2.3 From 9763e3f0b2b9de23231a45274bd534889a7d478c Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 29 Sep 2026 09:08:24 -0700 Subject: A face still being looked up shows its punk after a second at most; a picture found later replaces it. The quiet loading circle is capped at 1 s per face (a timer repaints when it runs out); one miss is enough to remember someone as faceless; and a room's warm-up adds members whose messages arrived after it first ran, instead of reusing a warm-up that covered only the early history. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp --- src/features/messages.js | 42 +++++++++++++++++++++++++++++------------- tools/room-faces-test.js | 7 +++++-- 2 files changed, 34 insertions(+), 15 deletions(-) (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index a86d0a9..00f66b9 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -953,9 +953,9 @@ export function messagesFeature(ctx) { const prev = profiles.get(pk); const miss = ((prev && prev.miss) || 0) + 1; profiles.set(pk, { ...(prev || {}), t: Date.now(), miss }); - // twice running: not a cold relay, they have none (never someone - // whose face we do know) - if (miss >= 2 && !(prev && (prev.name || prev.picture))) markFaceless(pk, true); + // remembered, so the next load paints their punk at once (never + // someone whose face we do know; a face found later replaces it) + if (!(prev && (prev.name || prev.picture))) markFaceless(pk, true); } scheduleRepaint(); } finally { @@ -2918,6 +2918,18 @@ export function messagesFeature(ctx) { return (big ? `url(${JSON.stringify(p.picture)}),` : '') + `url(${JSON.stringify(local)})`; } + const FACE_WAIT_MS = 1000; + const faceAsked = new Map(); // pk -> when its face was first wanted this session + let faceTimer = null; + function faceWaiting(pk) { + let at = faceAsked.get(pk); + if (at == null) { at = Date.now(); faceAsked.set(pk, at); } + const left = at + FACE_WAIT_MS - Date.now(); + if (left <= 0) return false; + // repaint when the wait runs out, so the punk shows without an event + if (!faceTimer) faceTimer = setTimeout(() => { faceTimer = null; scheduleRepaint(); }, left + 20); + return true; + } const avatar = (pk, cls = 'chat-avatar', clickable = true) => { const p = profileOf(pk); // Someone we've never cached used to get an empty circle until a relay @@ -2931,12 +2943,15 @@ export function messagesFeature(ctx) { // `loading` is different and still gets the quiet circle: a name lookup // is in flight for that specific person, so a picture is expected and // punk art must not flash in front of it. + // A face still being looked up gets a quiet circle for a second at + // most, then its punk — a picture found later replaces it. + const waiting = (p === null || (p && p.loading && !p.picture)) && faceWaiting(pk); const node = feedPaint && !p.picture ? h('div', { class: cls + ' fallback' }, (p.name || npubOf(pk) || '??').slice(0, 2)) - : p && p.loading && !p.picture - ? h('div', { class: cls + ' fallback loading' }) - : p === null && (ui.noteThread || (ui.chatOpen && ui.msgView === 'room')) + : waiting && (p || ui.noteThread || (ui.chatOpen && ui.msgView === 'room')) ? h('div', { class: cls + ' fallback loading' }) + : p && p.loading && !p.picture + ? fallbackAvatar(h, pk, p.name, cls) : p === null ? fallbackAvatar(h, pk, null, cls) : p.picture @@ -8743,15 +8758,16 @@ export function messagesFeature(ctx) { .sort((a, b) => eventMs(b.rumor) - eventMs(a.rumor)); return [...new Set(msgs.map((m) => m.author))].slice(0, 150); } + // Each face is warmed once per half minute; a later call (the history + // still streaming in when the list first warmed) adds whoever is new. function warmRoom(jm) { - const w = roomWarm.get(jm.community_id); - if (w && Date.now() - w.at < 30_000) return w.promise; + let w = roomWarm.get(jm.community_id); + if (!w || Date.now() - w.at > 30_000) { w = { at: Date.now(), faces: new Map() }; roomWarm.set(jm.community_id, w); } const deadline = Date.now() + 8000; - let promise; - try { promise = Promise.all(roomFaces(jm).map((pk) => warmAvatar(pk, deadline).catch(() => {}))); } - catch { promise = Promise.resolve(); } - roomWarm.set(jm.community_id, { at: Date.now(), promise }); - return promise; + try { + for (const pk of roomFaces(jm)) if (!w.faces.has(pk)) w.faces.set(pk, warmAvatar(pk, deadline).catch(() => {})); + } catch {} + return Promise.all(w.faces.values()); } async function openRoomWarm(jm, go) { const seq = ++roomOpenSeq; diff --git a/tools/room-faces-test.js b/tools/room-faces-test.js index 706691b..ab673ff 100644 --- a/tools/room-faces-test.js +++ b/tools/room-faces-test.js @@ -45,7 +45,10 @@ try { if (!process.argv.includes('--no-hover')) await row.hover(); await row.click(); for (let i = 0; i < 60 && !(await page.evaluate(() => !!window.__first)); i++) await sleep(100); - await sleep(6000); + await sleep(1200); + const at1s = await page.evaluate(new Function('return (' + face.toString() + ')()')); + check('no face is still a blank circle a second after the room shows', !at1s.includes('wait'), JSON.stringify(tally(at1s))); + await sleep(4800); const [first, at] = await page.evaluate(() => [window.__first, window.__firstAt]); const last = await page.evaluate(new Function('return (' + face.toString() + ')()')); const flips = (first || []).filter((f, i) => f === 'punk' && last[i] === 'pic').length; @@ -59,7 +62,7 @@ try { // frame — a known-faceless member is their punk at once, not a blank // circle that turns into one. (Wait out the second ask that confirms // who has no profile.) - await sleep(25000); + await sleep(8000); await page.evaluateOnNewDocument((fs, ks) => { const face = new Function('return ' + fs)(); const keyed = new Function('return ' + ks)(); -- cgit v1.2.3 From 3948ad95beb6e5e40dd275b7c4909ec8c4dee8a9 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 29 Sep 2026 11:01:43 -0700 Subject: Quoted notes are found on more relays and never show as a bare "view note"; Back from a post keeps the feed's place. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - findNote: the reference's hints, its author's NIP-65 relays and ours, plus a wide net of big public relays, all at once, first answer wins. A real quote (SuiGenerisJohn → 0c5e0f08…) sat only on nostr.mom, named by neither the nevent nor its author's relay list. - A feed row whose quote isn't in yet shows the quote card with "Fetching note…" / "Couldn't find that note" instead of a link; the card replaces it once found. - Opening a post remembers the scroll position like opening a profile does; Back restores it instead of landing at the top of the feed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp --- src/features/messages.js | 65 ++++++++++++++++++++++++++++++++------------ tools/avatar-profile-test.js | 11 ++++++++ tools/quote-card-test.js | 19 +++++++++---- 3 files changed, 73 insertions(+), 22 deletions(-) (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index 00f66b9..b5b4c01 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -3079,17 +3079,28 @@ export function messagesFeature(ctx) { // A profile opens at its top: keeping the feed's scroll offset landed the // reader deep in that person's posts, which read as a thread. Back (a // popstate) puts them where they were on the page they left. - const profReturn = []; // [{ pk, y }] per profile opened, newest last + // The same for a post opened into its thread: Back lands on the row the + // reader tapped, not the top of the feed. + const pageReturn = []; // [{ key, y }] per profile/thread opened, newest last + const pageKey = () => ui.noteThread && !(ui.profilePk && ui.profOverThread) ? 'thread' + : ui.profilePk ? 'p:' + ui.profilePk : ''; + function rememberPlace(key) { + if (pageKey() === key) return; + let y = 0; try { y = window.scrollY || 0; } catch {} + pageReturn.push({ key, y }); + if (pageReturn.length > 20) pageReturn.shift(); + } if (typeof window !== 'undefined') window.addEventListener('popstate', () => { - const top = profReturn[profReturn.length - 1]; - if (!top || ui.profilePk === top.pk) return; - profReturn.pop(); - requestAnimationFrame(() => { try { window.scrollTo(0, top.y); } catch {} }); + const top = pageReturn[pageReturn.length - 1]; + if (!top || pageKey() === top.key) return; + pageReturn.pop(); + const y = top.y; + // twice: the page under it may paint its rows a frame late + requestAnimationFrame(() => { try { window.scrollTo(0, y); } catch {} + requestAnimationFrame(() => { try { if (Math.abs(window.scrollY - y) > 2) window.scrollTo(0, y); } catch {} }); }); }); function openProfile(pk) { - let y = 0; try { y = window.scrollY || 0; } catch {} - if (ui.profilePk !== pk) profReturn.push({ pk, y }); - if (profReturn.length > 20) profReturn.shift(); + rememberPlace('p:' + pk); ui.profilePk = pk; // Opened from inside a thread (an author's avatar/name), the profile // stacks ON TOP of it — back returns to the conversation. The screen @@ -5389,15 +5400,35 @@ export function messagesFeature(ctx) { // wanted, half a minute on, with any relay hints the reference carried. const QUOTE_RETRY_MS = 30_000; const quoted = new Map(); // id -> { status, ev, at } + // Where a referenced note may be: the reference's hints, its author's own + // relays, ours — and when none of those has it, a wider net of big public + // relays (a quoted note in the wild sat only on nostr.mom, which neither + // the reference nor its author's relay list named). + const WIDE_RELAYS = ['wss://nostr.mom', 'wss://relay.nostr.band', 'wss://nostr.wine', 'wss://relay.snort.social', + 'wss://offchain.pub', 'wss://relay.nostr.bg', 'wss://nostr.oxtr.dev', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net']; + // All at once, first answer wins: asked in turn, a note on a far relay + // took longer than the feed waits for a row. + function findNote(ref) { + const pick = (evs) => (evs || []).find((e) => e.id === ref.id) || null; + const ask = (relays) => (relays.length ? queryOn(relays, { ids: [ref.id] }, 4500).then(pick).catch(() => null) : Promise.resolve(null)); + const near = [...new Set([...(ref.relays || []), ...zapRelays()])]; + const asks = [ + ask(near), + ref.author ? relaysOf(ref.author).catch(() => []).then((rs) => ask(rs.filter((r) => !near.includes(r)))) : Promise.resolve(null), + ask(WIDE_RELAYS.filter((r) => !near.includes(r))), + ]; + return new Promise((resolve) => { + let left = asks.length; + for (const a of asks) a.then((ev) => { if (ev) resolve(ev); else if (--left === 0) resolve(null); }); + }); + } function quotedNote(ref) { let c = quoted.get(ref.id); if (c && (c.status !== 'missing' || Date.now() - (c.at || 0) < QUOTE_RETRY_MS)) return c; if (!c) { c = { status: 'loading', ev: null, at: 0 }; quoted.set(ref.id, c); } c.at = Date.now(); c.promise = (async () => { - const relays = [...new Set([...(ref.relays || []), ...zapRelays()])]; - const evs = await queryOn(relays, { ids: [ref.id] }, 4500).catch(() => []); - c.ev = (evs || [])[0] || c.ev || null; + c.ev = (await findNote(ref).catch(() => null)) || c.ev || null; c.status = c.ev ? 'ready' : 'missing'; if (c.ev) cacheNotifNotes([c.ev]); // the open thread quotes it: keep it with the thread for the next reload @@ -5428,9 +5459,10 @@ export function messagesFeature(ctx) { let c = feedPaint ? feedPaint.quotes.get(ref.id) : quotedNote(ref); if (feedPaint && !c?.ev) { const live = quoteWarm(ref); - if (!(live.ev && live.warm)) return h('a', { - href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); openNoteRef(ref); }, - }, t('noteRefLink')); + // not (yet) found: the card says so where the quote will be — never a + // bare "view note" link that only leads to the same answer + if (!(live.ev && live.warm)) return h('div', { class: 'quote-card' }, + h('span', { class: 'small faint' }, t(live.status === 'missing' ? 'noteRefNotFound' : 'noteRefLoading'))); c = { ...live }; feedPaint.quotes.set(ref.id, c); } @@ -6900,6 +6932,7 @@ export function messagesFeature(ctx) { return c; } function openNoteThread(ev) { + rememberPlace('thread'); ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev, scrollPending: true }; ui.profOverThread = false; // a freshly opened thread goes on top render(); @@ -6912,9 +6945,7 @@ export function messagesFeature(ctx) { const cached = saved && (saved.root.id === ref.id ? saved.root : saved.replies.find((e) => e.id === ref.id)); if (cached) { openNoteThread(cached); return; } toast(t('noteRefLoading')); - const relays = [...new Set([...(ref.relays || []), ...NOTE_RELAYS])]; - const evs = await queryOn(relays, { ids: [ref.id] }, 4000).catch(() => []); - const ev = (evs || [])[0]; + const ev = quoted.get(ref.id)?.ev || await findNote(ref).catch(() => null); if (ev) openNoteThread(ev); else toast(t('noteRefNotFound')); } diff --git a/tools/avatar-profile-test.js b/tools/avatar-profile-test.js index 6e84429..54c40dc 100644 --- a/tools/avatar-profile-test.js +++ b/tools/avatar-profile-test.js @@ -69,6 +69,17 @@ try { } check('back returns to the feed where the reader was', back.feed && Math.abs(back.y - feedY) < 60, 'scrollY ' + back.y + ' vs ' + feedY); + // a post opened into its thread: Back lands where the reader was + await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.innerText.includes('post number 13')); r.scrollIntoView({ block: 'center' }); }); + await sleep(700); + const y0 = await page.evaluate(() => window.scrollY); + await page.evaluate(() => [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.innerText.includes('post number 13')).querySelector('.note-text').click()); + await sleep(1500); + const inThread = await page.evaluate(() => !!document.querySelector('.thread-page')); + await page.goBack(); await sleep(1500); + const y1 = await page.evaluate(() => window.scrollY); + check('back from a post returns to the same place in the feed', inThread && y0 > 500 && Math.abs(y1 - y0) < 60, 'scrollY ' + y1 + ' vs ' + y0); + // "Their timeline" from the profile: its chip names it (it was a blank pill) await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed .note-avatar')].find((n) => n.closest('[data-key]')?.innerText.includes('Bob Poster')); r.click(); }); await sleep(1000); diff --git a/tools/quote-card-test.js b/tools/quote-card-test.js index 91edf3b..d2d7d7e 100644 --- a/tools/quote-card-test.js +++ b/tools/quote-card-test.js @@ -25,6 +25,7 @@ const found = await pool.querySync(['wss://relay.primal.net', 'wss://nos.lol'], const target = (found || []).filter((e) => e.content && e.content.length > 20)[0]; if (!target) { console.log(' ✗ no note to quote (relays quiet)'); process.exit(1); } const nevent = neventOf(target.id, target.pubkey); +const FAR = 'nevent1qvzqqqqqqypzqntcggz30qhq60ltqdx32zku9d46unhrkjtcv7fml7jx3dh4h94nqqsqchs0pz4hnqm6jurakjwcd6wcrph7wyy80j0ljup68p4hynvsz3q3nhre2'; const html = await buildHtml({ minify: true, pwa: false }); const server = Bun.serve({ port: 5269, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); @@ -59,21 +60,24 @@ try { await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); const A = 'a'.repeat(63) + '9'; - await page.evaluate(([k, pk, ref]) => { + await page.evaluate(([k, pk, ref, FAR]) => { localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now()/1000) })); localStorage.setItem(k + ':feedNotes', JSON.stringify([ { id: 'd'.repeat(64), pubkey: pk, kind: 1, created_at: Math.floor(Date.now()/1000), content: 'what he said nostr:' + ref, tags: [] }, + // quotes a note that sits only on nostr.mom — not in the reference, not in its author's relay list + { id: 'e'.repeat(64), pubkey: pk, kind: 1, created_at: Math.floor(Date.now()/1000) - 60, content: 'I remember this.\n\nnostr:' + FAR, tags: [] }, ])); localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Quoter', t: Date.now() } })); - }, [base, A, nevent]); + }, [base, A, nevent, FAR]); if (process.argv.includes('--slow')) await page.evaluate(() => localStorage.setItem('__slowQuote', '1')); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click()); await sleep(7000); if (process.argv.includes('--slow')) { - for (let i = 0; i < 40 && !(await page.evaluate(() => document.body.innerText.includes('view note'))); i++) await sleep(250); - check('a late quote first paints as a link', await page.evaluate(() => document.body.innerText.includes('view note'))); + for (let i = 0; i < 40 && !(await page.evaluate(() => !!document.querySelector('.quote-card'))); i++) await sleep(250); + check('a late quote first paints as a placeholder card, not a link', await page.evaluate(() => !document.body.innerText.includes('view note') + && [...document.querySelectorAll('.quote-card')].some((q) => /Couldn.t find|Fetching note/.test(q.innerText)))); await sleep(SLOW_MS + 4000); } @@ -87,7 +91,12 @@ try { check('...with whose note it is', !!(card && card.hasAvatar), card ? card.text.slice(0, 50) : ''); const body = target.content.replace(/\s+/g, ' ').slice(0, 24); check('...and what it actually said', !!(card && card.text.replace(/\s+/g, ' ').includes(body.slice(0, 18))), body); - check('no "view note" link left in the post', !(await page.evaluate(() => document.body.innerText)).includes('view note')); + if (!process.argv.includes('--slow')) { + const far = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.innerText.includes('Quoter') && n.innerText.includes('I remember this')); return r && r.querySelector('.quote-card')?.innerText; }); + check('a quote found only on a far relay still shows as its card', !!far && !/Couldn.t find|Fetching/.test(far), JSON.stringify((far || '').slice(0, 60))); + } + // (a quote inside a quote stays a link on purpose: one level of cards) + check('no "view note" link left in the post', !(await page.evaluate(() => [...document.querySelectorAll('.notes-feed a')].some((a) => a.textContent === 'view note' && !a.closest('.quote-card'))))); // the outer post keeps its actions; the quote has none of its own const acts = await page.evaluate(() => ({ outer: document.querySelectorAll('.notes-feed > .row .note-acts').length, -- cgit v1.2.3 From 689e21d5e77765661c44d60c10b18134b995162a Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 29 Sep 2026 11:18:21 -0700 Subject: Composers take pictures from the keyboard: the reply, post and chat fields are contenteditable. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android keyboards (Gboard GIFs, stickers) only offer images to fields that accept them, and Chrome says so for contenteditable, never