From 356920e8aa7d27dd5e7eff070246ae9ed41498c7 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 28 Sep 2026 21:05:25 -0700 Subject: Restore public feed state when navigating Back from Wallet --- src/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src/app.js') diff --git a/src/app.js b/src/app.js index cb6a030..7dde883 100644 --- a/src/app.js +++ b/src/app.js @@ -737,7 +737,7 @@ wallet.subscribe(scheduleRender); // lightbox: a full-screen photo is a place too — the phone's Back must close // it, not pop the screen under it (which once walked a viewer straight back // to the start page, so the photo's × then looked like a logout). -const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'arkMoveDetail', 'arkReconDetail', 'arkExitDetail', 'giftDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'feedId', 'arkCoinsPage', 'arkExitPage', 'msgCommunity', 'msgPeer', 'profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'settingsPage', 'nameEditOpen', 'noteThread', 'userSearch', 'zapSetup', 'hatShop', 'lightbox']; +const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'arkMoveDetail', 'arkReconDetail', 'arkExitDetail', 'giftDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'feedId', 'pubProf', 'arkCoinsPage', 'arkExitPage', 'msgCommunity', 'msgPeer', 'profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'settingsPage', 'nameEditOpen', 'noteThread', 'userSearch', 'zapSetup', 'hatShop', 'lightbox']; function navSnapshot() { const s = {}; for (const f of NAV_FIELDS) s[f] = ui[f] ?? null; -- 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/app.js') 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 d74b1ad667abea3b82bdb4bbddb9f1d0047903d4 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 28 Sep 2026 22:05:29 -0700 Subject: Send suggestions keep their rows across renders, so hovering one no longer flickers. The panel was swapped for fresh nodes on every background render, which dropped :hover each time. Now it's one node for the page's life and its rows are rebuilt only when the results change. Co-Authored-By: Claude Opus 5.5 --- src/app.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) (limited to 'src/app.js') diff --git a/src/app.js b/src/app.js index 5e3fba4..febb2cd 100644 --- a/src/app.js +++ b/src/app.js @@ -4832,7 +4832,7 @@ function destReady(a) { // Recipient search under the destination input: usernames and npubs resolve // to candidates with avatars; picking one runs the same path as pasting. -const sendSearch = { rows: null, sync: null }; +const sendSearch = { rows: null, sync: null, pick: null, el: null }; let sendRevealTimer = null; // With the phone keyboard up, the space under the recipient field is scarce — // park the field at the top of the view so the candidate list gets what's @@ -4882,12 +4882,19 @@ function recipientRow(s, r, i) { }; // The suggestions panel gets the same imperative treatment — see the // sendSearcher note: a render between key repeats kills backspace-hold. - const suggest = i === 0 ? h('div', { class: 'list send-suggest', style: 'display:none', 'data-fresh': '1' }) : null; + // One panel for the life of the page, rows rebuilt only when the results + // change: a background render must not swap the row under the pointer + // (a fresh node drops :hover, and the highlight blinked on every tick). + // data-fresh still installs this exact node wherever the morph lands it. + const suggest = i === 0 ? (sendSearch.el ||= h('div', { class: 'list send-suggest', style: 'display:none', 'data-fresh': '1' })) : null; const syncSuggest = () => { if (!suggest) return; - const show = sendSearch.rows && sendSearch.rows.length && searchable(r.address); - suggest.replaceChildren(...(show ? resultRows(h, sendSearch.rows, pickRecipient, (pk, node) => featureHook('wrapAvatar', pk, node)) : [])); - suggest.style.display = show ? '' : 'none'; + const show = !!(sendSearch.rows && sendSearch.rows.length && searchable(r.address)); + const rows = show ? sendSearch.rows : null; + if (suggest._rows === rows) return; + suggest._rows = rows; + suggest.replaceChildren(...(rows ? resultRows(h, rows, (cand) => sendSearch.pick(cand), (pk, node) => featureHook('wrapAvatar', pk, node)) : [])); + suggest.style.display = rows ? '' : 'none'; }; if (i === 0) sendSearch.sync = syncSuggest; @@ -4982,6 +4989,7 @@ function recipientRow(s, r, i) { ) : null ); syncCheck(); + if (i === 0) sendSearch.pick = pickRecipient; // the persistent rows call this render's syncSuggest(); return row; } -- cgit v1.2.3 From a31d97220c8acb858c71301972f38597c1eb2270 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 28 Sep 2026 22:28:05 -0700 Subject: Pinch to zoom a full-screen picture. Pinch zooms around the fingers (up to 5x), one finger pans a zoomed picture within its edges, double-tap zooms in at a spot and again to reset, and the wheel zooms on a desktop. A tap still closes an unzoomed picture (after a beat, so it can be the first half of a double-tap); a gesture never does. Co-Authored-By: Claude Opus 5.5 --- src/app.js | 102 ++++++++++++++++++++++++++++++++++++++++++++++- src/style.css | 2 + tools/image-zoom-test.js | 84 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 tools/image-zoom-test.js (limited to 'src/app.js') diff --git a/src/app.js b/src/app.js index febb2cd..4003e1b 100644 --- a/src/app.js +++ b/src/app.js @@ -561,12 +561,110 @@ setTimeout(() => { if (_bootDeciding) { _bootDeciding = false; render(); } }, 40 function imageViewer() { if (!ui.lightbox) return null; const close = () => goBack(() => { ui.lightbox = null; }); - return h('div', { + // a tap on the picture closes it too (the × is small and far) — unless + // it's zoomed, or the tap is half of a double-tap (see attachZoom) + const img = h('img', { src: ui.lightbox, alt: '', draggable: 'false' }); + const box = h('div', { class: 'lightbox', onClick: close, role: 'dialog', 'aria-modal': 'true', - }, h('img', { src: ui.lightbox, alt: '', onClick: (e) => { e.stopPropagation(); close(); } }), // a tap on the picture closes it too: the × is small and far + }, img, // stop the bubble: the backdrop closes too, and two pops walk out of the room h('button', { class: 'lightbox-x', 'aria-label': t('close'), onClick: (e) => { e.stopPropagation(); close(); } }, '\u00d7')); + attachZoom(box, img, close); + return box; +} +// Pinch to zoom (up to 5×) around the fingers, drag to pan a zoomed picture, +// double-tap (or double-click) to zoom in at a spot and again to reset, the +// wheel to zoom on a desktop. The page's viewport meta turns browser zoom +// off, so the picture does its own. Only the first-mounted nodes keep these +// listeners: later renders morph into them, and the img is left out of the +// morph so its transform survives a background render. +function attachZoom(box, img, close) { + img._skipMorph = true; + const MAX = 5, pts = new Map(); + let s = 1, x = 0, y = 0, pinch = null, pan = null, moved = false, lastTap = 0, tapTimer = 0; + const base = () => ({ bx: img.offsetLeft + img.offsetWidth / 2, by: img.offsetTop + img.offsetHeight / 2 }); + const clamp = () => { + if (s <= 1) { s = 1; x = 0; y = 0; return; } + const mx = Math.max(0, (img.offsetWidth * s - window.innerWidth) / 2), my = Math.max(0, (img.offsetHeight * s - window.innerHeight) / 2); + x = Math.min(mx, Math.max(-mx, x)); y = Math.min(my, Math.max(-my, y)); + }; + const apply = (animate) => { + img.style.transition = animate ? 'transform .2s ease' : 'none'; + img.style.transform = s === 1 && !x && !y ? '' : `translate(${x}px, ${y}px) scale(${s})`; + img.style.cursor = s > 1 ? 'grab' : ''; + }; + // scale to ns keeping the picture point under (cx, cy) where it is + const zoomAt = (ns, cx, cy) => { + const { bx, by } = base(); + const px = (cx - bx - x) / s, py = (cy - by - y) / s; + s = Math.min(MAX, Math.max(1, ns)); + x = cx - bx - px * s; y = cy - by - py * s; + }; + box.addEventListener('pointerdown', (e) => { + if (e.target.closest('.lightbox-x')) return; + pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (pts.size === 1) moved = false; + begin(); + }); + const begin = () => { + const p = [...pts.values()]; + if (p.length >= 2) { + const { bx, by } = base(); + const mx = (p[0].x + p[1].x) / 2, my = (p[0].y + p[1].y) / 2; + pinch = { d: Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y) || 1, s, px: (mx - bx - x) / s, py: (my - by - y) / s }; + pan = null; + } else if (p.length === 1) { + pinch = null; + pan = { sx: p[0].x, sy: p[0].y, x, y }; + } + }; + box.addEventListener('pointermove', (e) => { + if (!pts.has(e.pointerId)) return; + pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); + const p = [...pts.values()]; + if (pinch && p.length >= 2) { + const { bx, by } = base(); + const d = Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y); + const mx = (p[0].x + p[1].x) / 2, my = (p[0].y + p[1].y) / 2; + s = Math.min(MAX, Math.max(0.8, pinch.s * d / pinch.d)); // a little give below 1, snapped back on release + x = mx - bx - pinch.px * s; y = my - by - pinch.py * s; + moved = true; + apply(false); + } else if (pan && p.length === 1) { + const dx = p[0].x - pan.sx, dy = p[0].y - pan.sy; + if (Math.abs(dx) + Math.abs(dy) > 8) moved = true; + if (s > 1) { x = pan.x + dx; y = pan.y + dy; clamp(); apply(false); } + } + }); + const end = (e) => { + if (!pts.delete(e.pointerId)) return; + if (pts.size) { begin(); return; } // one finger lifted mid-pinch: carry on as a pan + pinch = pan = null; + clamp(); apply(true); + }; + box.addEventListener('pointerup', end); + box.addEventListener('pointercancel', end); + // a drag or a pinch is not a tap: nothing closes after one + box.addEventListener('click', (e) => { + if (moved) { moved = false; e.stopImmediatePropagation(); e.preventDefault(); return; } + if (e.target !== img) return; + e.stopPropagation(); + const now = Date.now(); + if (now - lastTap < 300) { + clearTimeout(tapTimer); lastTap = 0; + if (s > 1) { s = 1; x = 0; y = 0; } else { zoomAt(2.5, e.clientX, e.clientY); clamp(); } + apply(true); + return; + } + lastTap = now; + if (s === 1) tapTimer = setTimeout(close, 300); + }, true); + box.addEventListener('wheel', (e) => { + e.preventDefault(); + zoomAt(s * Math.exp(-e.deltaY * 0.002), e.clientX, e.clientY); + clamp(); apply(false); + }, { passive: false }); } if (typeof window !== 'undefined') { window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && ui.lightbox) goBack(() => { ui.lightbox = null; }); }); diff --git a/src/style.css b/src/style.css index e907bd5..15fcad0 100644 --- a/src/style.css +++ b/src/style.css @@ -726,6 +726,7 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; } display: flex; align-items: center; justify-content: center; background: rgba(8, 10, 14, .94); padding: 16px; animation: lightbox-in .12s ease-out; + touch-action: none; /* pinch and drag are the picture's own (see attachZoom) */ } /* Nothing scrolls behind a picture that fills the screen — and the reserved scrollbar gutter (see html, top of file) stands down with it, or it shows @@ -737,6 +738,7 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } .lightbox img { max-width: 100%; max-height: 100svh; max-height: 100vh; object-fit: contain; border-radius: 6px; cursor: zoom-out; + user-select: none; -webkit-user-select: none; transform-origin: center center; will-change: transform; } .lightbox-x { position: absolute; top: max(10px, env(safe-area-inset-top)); right: 10px; diff --git a/tools/image-zoom-test.js b/tools/image-zoom-test.js new file mode 100644 index 0000000..3be5798 --- /dev/null +++ b/tools/image-zoom-test.js @@ -0,0 +1,84 @@ +// The full-screen picture: pinch to zoom, pan when zoomed, double-tap to zoom +// and reset, a background render keeps the zoom, taps after a gesture don't +// close it, a plain tap still does, and the wheel zooms on a desktop. +// Run: bun tools/image-zoom-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 viewer = app.slice(app.indexOf('function imageViewer()'), app.indexOf("if (typeof window !== 'undefined') {\n window.addEventListener('keydown'")); +const svg = encodeURIComponent(''); +const entry = ` +${dom} +${viewer} +const ui = { lightbox: 'data:image/svg+xml,${svg}' }; +const t = (k) => k; +const goBack = (fn) => { fn(); render(); }; +function render() { morphChildren(document.querySelector('#app'), ui.lightbox ? [imageViewer()] : []); } +window.test = { ui, render, open() { ui.lightbox = 'data:image/svg+xml,${svg}'; render(); } }; +render(); +`; +const bundle = await Bun.build({ entrypoints: ['zoom-entry'], target: 'browser', plugins: [{ name: 'zoom', setup(build) { + build.onResolve({ filter: /^zoom-entry$/ }, () => ({ path: 'entry', namespace: 'zoom' })); + build.onLoad({ filter: /.*/, namespace: 'zoom' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() })); +} }] }); +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)); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const zoom = () => page.evaluate(() => { const m = new DOMMatrix(getComputedStyle(document.querySelector('.lightbox img')).transform); return { s: +m.a.toFixed(2), x: Math.round(m.e), y: Math.round(m.f) }; }); +const open = () => page.evaluate(() => !!test.ui.lightbox && !!document.querySelector('.lightbox')); +try { + await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); + await page.goto(server.url.href); + await page.waitForSelector('.lightbox img'); + const cdp = await page.target().createCDPSession(); + const touch = (type, points) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints: points.map(([x, y], id) => ({ x, y, id })) }); + const cx = 195, cy = 422; + // pinch out around the centre + await touch('touchStart', [[cx - 40, cy], [cx + 40, cy]]); + for (let i = 1; i <= 8; i++) { await touch('touchMove', [[cx - 40 - i * 15, cy], [cx + 40 + i * 15, cy]]); await sleep(16); } + await touch('touchEnd', []); await sleep(350); + let z = await zoom(); + assert(z.s > 3.8 && z.s < 4.2, 'pinch zooms with the fingers (80px → 320px apart = 4×): ' + JSON.stringify(z)); + assert(await open(), 'a pinch does not close the picture'); + // a background render keeps the zoom + await page.evaluate(() => test.render()); await sleep(50); + assert.equal((await zoom()).s, z.s, 'a background render keeps the zoom'); + // one finger pans the zoomed picture, clamped to its edges + await touch('touchStart', [[cx, cy]]); + for (let i = 1; i <= 10; i++) { await touch('touchMove', [[cx + i * 20, cy + i * 5]]); await sleep(16); } + await touch('touchEnd', []); await sleep(350); + const panned = await zoom(); + assert(panned.x > z.x + 50, 'drag pans: ' + JSON.stringify(panned)); + const maxX = await page.evaluate((s) => (document.querySelector('.lightbox img').offsetWidth * s - innerWidth) / 2, panned.s); + assert(panned.x <= maxX + 1, 'pan is held to the picture edge'); + assert(await open(), 'a drag does not close the picture'); + // a tap while zoomed does not close; a double-tap resets + await page.touchscreen.tap(cx, cy); await sleep(400); + assert(await open(), 'a tap while zoomed does not close'); + await page.touchscreen.tap(cx, cy); await sleep(80); await page.touchscreen.tap(cx, cy); await sleep(350); + assert.equal((await zoom()).s, 1, 'double-tap resets the zoom'); + // double-tap at 1× zooms in at the spot, without closing + await page.touchscreen.tap(cx + 60, cy); await sleep(80); await page.touchscreen.tap(cx + 60, cy); await sleep(400); + z = await zoom(); + assert(z.s === 2.5 && z.x < 0, 'double-tap zooms in toward the tapped spot: ' + JSON.stringify(z)); + assert(await open(), 'a double-tap does not close'); + await page.touchscreen.tap(cx, cy); await sleep(80); await page.touchscreen.tap(cx, cy); await sleep(350); + // a single tap at 1× still closes + await page.touchscreen.tap(cx, cy); await sleep(450); + assert(!(await open()), 'a single tap closes the picture'); + // desktop: the wheel zooms toward the cursor + await page.setViewport({ width: 1280, height: 800 }); + await page.evaluate(() => test.open()); await page.waitForSelector('.lightbox img'); + await page.mouse.move(700, 400); + for (let i = 0; i < 5; i++) { await page.mouse.wheel({ deltaY: -120 }); await sleep(30); } + z = await zoom(); + assert(z.s > 1.5, 'the wheel zooms in: ' + JSON.stringify(z)); + assert(await open(), 'wheel zoom leaves the picture open'); + assert.deepEqual(errors, []); + console.log('✓ Image viewer: pinch zooms, drag pans within the edges, double-tap zooms and resets, renders keep the zoom, gestures never close it, a tap does, the wheel zooms on desktop'); +} finally { await browser.close(); server.stop(true); } -- 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/app.js') diff --git a/src/app.js b/src/app.js index 4003e1b..203b50a 100644 --- a/src/app.js +++ b/src/app.js @@ -9,6 +9,7 @@ import { qrSvg } from './qr.js'; import { makeSearcher, resultRows, searchable, punkUrl, warmSearch } from './recipient-search.js'; import { npubOf, seedPubkey, neventOf } from './nostr.js'; import { nip98Header } from './nip98.js'; +import { uploadPublicMedia, MediaUploadError } from './media-upload.js'; import { NOSTR_MARK } from './features/nostrlogin.js'; import { scanQr } from './scan.js'; import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js'; @@ -3452,21 +3453,27 @@ async function onbUpload(file) { const fd = new FormData(); fd.append('file', file); const endpoint = 'https://nostr.build/api/v2/upload/files'; - // nostr.build no longer takes anonymous uploads — sign with the wallet's - // nostr key, the one key that's always available without a signer prompt. - const headers = {}; - try { - if (wallet.nostrPubkey && wallet.nostrPubkey()) { - headers.authorization = await nip98Header({ signEvent: (e) => wallet.nostrSign(e) }, endpoint, 'POST'); - } - } catch {} - const r = await fetch(endpoint, { method: 'POST', body: fd, headers }); - const j = await r.json(); - const url = j?.data?.[0]?.url; + const authorization = await nip98Header({ signEvent: (event) => wallet.nostrSign(event) }, endpoint, 'POST'); + const response = await fetch(endpoint, { + method: 'POST', body: fd, headers: { authorization }, signal: AbortSignal.timeout(60_000), + }); + let body = null; + try { body = await response.json(); } catch {} + const url = response.ok && body?.data?.[0]?.url; if (!url) throw new Error(t('onbUploadFailed')); return url; } +async function publicMediaUpload(file) { + try { + return await uploadPublicMedia(file, (event) => wallet.nostrSign(event)); + } catch (error) { + if (error instanceof MediaUploadError && error.code === 'too-big') throw new Error(t('mediaUploadTooBig')); + if (error instanceof MediaUploadError && error.code === 'unsupported') throw new Error(t('mediaUploadUnsupported')); + throw new Error(t('msgUploadFailed')); + } +} + // Whether the wizard can offer Spending at all: an Ark-capable build on a // network with the names registrar, for a full (signing) account. Elsewhere // the offer would be a spinner that never resolves. @@ -5720,9 +5727,10 @@ const ctx = { setAccount: (a, dir) => setAccountSel(a, dir), // Open (or create) a wallet from a mnemonic — used by nostr login. openMnemonic: async (mnemonic, passphrase, opts) => enterWallet(mnemonic, passphrase, opts), - // Upload an image (avatar) and get back its URL — the same signed - // nostr.build path the onboarding picker uses. + // Profile pictures retain the metadata-scrubbing image uploader. Public + // post media uses resilient Blossom storage and media-specific failures. uploadImage: (file) => onbUpload(file), + uploadMedia: (file) => publicMediaUpload(file), // A nostr login that lands mid-wizard: a restored wallet has been through // onboarding elsewhere, so the wizard ends without tour or prompts; a fresh // one falls through to the wallet + tour on the next render. diff --git a/src/features/messages.js b/src/features/messages.js index 7505dbf..fb88a56 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -6995,7 +6995,7 @@ export function messagesFeature(ctx) { // A reply can carry a picture too — same upload, same imeta tag, same // paperclip. The URL lands in the draft, which is what every client // reads as the media. - ctx.uploadImage ? h('button', { + (ctx.uploadMedia || ctx.uploadImage) ? h('button', { class: 'attach-btn', title: t('feedAttach'), 'aria-label': t('feedAttach'), disabled: !!ui.postUploading, onClick: () => document.getElementById('reply-file')?.click(), }, ui.postUploading ? h('span', { class: 'spinner sm' }) : h('span', { style: 'display:flex', html: CLIP })) : null, @@ -7588,10 +7588,11 @@ export function messagesFeature(ctx) { const composeText = () => (ui.profCompose == null ? draftFor(POST_DRAFT) : ui.profCompose || ''); // Upload, then hand the URL to whichever draft asked for it. async function attachTo(file, place) { - if (!file || !ctx.uploadImage) return; + const upload = ctx.uploadMedia || ctx.uploadImage; + if (!file || !upload) return; ui.postUploading = true; render(); try { - const url = await ctx.uploadImage(file); + const url = await upload(file); (ui.postMedia ||= []).push({ url, m: file.type || '' }); place(url); } catch (e) { toast(e.message || String(e)); } @@ -7667,7 +7668,7 @@ export function messagesFeature(ctx) { render(); } } }, t('profPostBtn')), - ctx.uploadImage ? h('button', { + (ctx.uploadMedia || ctx.uploadImage) ? h('button', { class: 'attach-btn', title: t('feedAttach'), 'aria-label': t('feedAttach'), disabled: !!ui.postUploading, onClick: () => document.getElementById('post-file')?.click(), }, ui.postUploading ? h('span', { class: 'spinner sm' }) : h('span', { style: 'display:flex', html: CLIP })) : null, @@ -7804,7 +7805,10 @@ export function messagesFeature(ctx) { class: 'app-bottom-nav', 'data-key': 'app-bottom-nav', }, button('feed', t('feedTitle'), '', () => { - if (active === 'feed' && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread && !ui.feedEdit) { jumpToNew(); return; } + // A destination tab is a destination: tapping Feed while already + // there always returns to its top. Only the explicit "x new posts" + // pill jumps to the first unread arrival. + if (active === 'feed' && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread && !ui.feedEdit) { glideToTop(); return; } leavePage(); if (visitor) { ui.pubProf = true; openFirehose(); } else showFeed(curFeedId); window.scrollTo({ top: 0 }); diff --git a/src/i18n.js b/src/i18n.js index afc76ef..475f919 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -198,7 +198,9 @@ const STR = { msgAttachFailed: 'Attachment unavailable', msgAttach: 'Attach a picture', msgAttachTooBig: 'That file is too big (20 MB max)', - msgUploadFailed: 'Upload failed. Try again', + msgUploadFailed: 'Upload failed. Check your connection and try again', + mediaUploadTooBig: 'That file is too big for the available media hosts', + mediaUploadUnsupported: 'That file format is not supported', msgPhoto: 'Photo', searchPeopleFor: 'Search people for {q}', msgEmojiSearch: 'Search emoji…', @@ -1006,9 +1008,9 @@ const STR = { onbNameWait: 'Getting things ready…', onbNotNow: 'Not now', onbAvatarTitle: 'Choose your look', - onbAvatarBody: 'Pick a punk or upload your own picture. You can change it anytime.', + onbAvatarBody: 'Choose an avatar or upload your own picture. You can change it anytime.', onbUpload: 'Upload a photo', - onbUploadFailed: 'Upload failed. Try a punk for now', + onbUploadFailed: 'Upload failed. Try another picture', onbProfileSkipped: 'Couldn’t publish your profile. Set it later from Edit profile', onbContinue: 'Continue', onbSkipAvatar: 'Continue with my default', diff --git a/src/locales/ar.json b/src/locales/ar.json index 6763c56..2e914c3 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -616,9 +616,9 @@ "onbNameWait": "جهز الأشياء", "onbNotNow": "ليس الآن", "onbAvatarTitle": "اختر مظهرك", - "onbAvatarBody": "إلتقط قطعة أو ارفع صورتك يمكنك تغييره في أي وقت", + "onbAvatarBody": "اختر صورة رمزية أو ارفع صورتك. يمكنك تغييرها في أي وقت", "onbUpload": "تحميل الصورة", - "onbUploadFailed": "لقد فشل التحميل - حاول أن تفشل الآن", + "onbUploadFailed": "فشل الرفع. جرّب صورة أخرى", "onbProfileSkipped": "لم يتمكن من نشر ملفك - وضعه في وقت لاحق من ملف إديت", "onbContinue": "استمر", "onbSkipAvatar": "استمر في إخفاقي", diff --git a/src/locales/bn.json b/src/locales/bn.json index 8c52809..44b2700 100644 --- a/src/locales/bn.json +++ b/src/locales/bn.json @@ -616,9 +616,9 @@ "onbNameWait": "জিনিস প্রস্তুত করা হচ্ছে...", "onbNotNow": "এই মুহূর্তে নয়", "onbAvatarTitle": "আপনার চেহারা পছন্দ করুন", - "onbAvatarBody": "তোমার নিজের ছবি আপলোড করে নাও। তুমি যেকোন সময় এটাকে পরিবর্তন করতে পারো।", + "onbAvatarBody": "একটি অবতার বেছে নাও অথবা নিজের ছবি আপলোড করো। যেকোনো সময় এটি বদলাতে পারো।", "onbUpload": "একটি ছবি আপলোড করুন", - "onbUploadFailed": "আপলোড করতে ব্যর্থ - এখন একটা বাজে প্রচেষ্টা করুন", + "onbUploadFailed": "আপলোড ব্যর্থ হয়েছে। অন্য একটি ছবি চেষ্টা করো", "onbProfileSkipped": "প্রোফাইল সম্পাদনা করতে ব্যর্থ - পরে নির্ধারণ করা সম্ভব নয়", "onbContinue": "এগিয়ে চলুন", "onbSkipAvatar": "ডিফল্ট হিসেবে নির্দিষ্ট করো", diff --git a/src/locales/de.json b/src/locales/de.json index 6409d81..bf827b4 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -616,9 +616,9 @@ "onbNameWait": "Bereitmachen...", "onbNotNow": "Nicht jetzt", "onbAvatarTitle": "Wählen Sie Ihren Look", - "onbAvatarBody": "Wählen Sie einen Punk oder laden Sie Ihr eigenes Bild hoch. Sie können es jederzeit ändern.", + "onbAvatarBody": "Wählen Sie einen Avatar oder laden Sie Ihr eigenes Bild hoch. Sie können ihn jederzeit ändern.", "onbUpload": "Foto hochladen", - "onbUploadFailed": "Upload fehlgeschlagen Versuchen Sie einen Punk für jetzt", + "onbUploadFailed": "Upload fehlgeschlagen. Versuchen Sie ein anderes Bild", "onbProfileSkipped": "Konnte dein Profil nicht publizieren", "onbContinue": "Weiter", "onbSkipAvatar": "Weiter mit meinem Standard", diff --git a/src/locales/es.json b/src/locales/es.json index 6cf00b6..52e417b 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -616,9 +616,9 @@ "onbNameWait": "Preparando las cosas...", "onbNotNow": "Ahora no.", "onbAvatarTitle": "Elige tu look", - "onbAvatarBody": "Escoge un punk o sube tu propia imagen. Puedes cambiarlo en cualquier momento.", + "onbAvatarBody": "Escoge un avatar o sube tu propia imagen. Puedes cambiarlo en cualquier momento.", "onbUpload": "Sube una foto", - "onbUploadFailed": "Subir falló, probar un punk por ahora", + "onbUploadFailed": "La subida falló. Prueba con otra imagen", "onbProfileSkipped": "No se pudo publicar su perfil - establecerlo más tarde del perfil Editar", "onbContinue": "Continuar", "onbSkipAvatar": "Continuar con mi defecto", diff --git a/src/locales/fr.json b/src/locales/fr.json index 9dd56c0..471eb5b 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -616,9 +616,9 @@ "onbNameWait": "Préparer les choses...", "onbNotNow": "Pas maintenant.", "onbAvatarTitle": "Choisissez votre look", - "onbAvatarBody": "Choisissez un punk ou téléchargez votre propre photo. Tu peux le changer à tout moment.", + "onbAvatarBody": "Choisissez un avatar ou téléchargez votre propre photo. Vous pouvez le changer à tout moment.", "onbUpload": "Télécharger une photo", - "onbUploadFailed": "Télécharger échoué, essayez un punk pour l'instant", + "onbUploadFailed": "Le téléversement a échoué. Essayez une autre image", "onbProfileSkipped": "Impossible de publier votre profil, définissez-le plus tard à partir du profil Édition", "onbContinue": "Continuer", "onbSkipAvatar": "Continuer avec mon défaut", diff --git a/src/locales/hi.json b/src/locales/hi.json index be3e6a3..4fcd47d 100644 --- a/src/locales/hi.json +++ b/src/locales/hi.json @@ -616,9 +616,9 @@ "onbNameWait": "चीजों को तैयार करना", "onbNotNow": "अब नहीं", "onbAvatarTitle": "अपना लुक चुनें", - "onbAvatarBody": "एक पंक चुनें या अपनी खुद की तस्वीर अपलोड करें। आप इसे कभी भी बदल सकते हैं।", + "onbAvatarBody": "एक अवतार चुनें या अपनी तस्वीर अपलोड करें। आप इसे कभी भी बदल सकते हैं।", "onbUpload": "फ़ोटो अपलोड करें", - "onbUploadFailed": "असफल रहा - अब के लिए एक पंक का प्रयास करें", + "onbUploadFailed": "अपलोड विफल रहा। कोई दूसरी तस्वीर आज़माएँ", "onbProfileSkipped": "अपने प्रोफ़ाइल को प्रकाशित नहीं कर सका - इसे बाद में संपादित प्रोफ़ाइल से सेट करें", "onbContinue": "जारी", "onbSkipAvatar": "जारी रखें मेरी डिफ़ॉल्ट के साथ", diff --git a/src/locales/ja.json b/src/locales/ja.json index d9cd967..2ab8916 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -616,9 +616,9 @@ "onbNameWait": "物事を準備する...", "onbNotNow": "今は今", "onbAvatarTitle": "お問い合わせ", - "onbAvatarBody": "パンクを選ぶか、自分の写真をアップロードしてください。 いつでも変更できます。", + "onbAvatarBody": "アバターを選ぶか、自分の写真をアップロードしてください。いつでも変更できます。", "onbUpload": "写真をアップロードする", - "onbUploadFailed": "アップロード失敗, 今すぐパンクを試してみる", + "onbUploadFailed": "アップロードに失敗しました。別の画像をお試しください", "onbProfileSkipped": "プロフィールを公開できません。編集プロファイルから後で設定できます。", "onbContinue": "お問い合わせ", "onbSkipAvatar": "デフォルトで続ける", diff --git a/src/locales/ko.json b/src/locales/ko.json index 6413666..e125415 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -615,9 +615,9 @@ "onbNameWait": "준비하는 것들 ...", "onbNotNow": "현재 위치", "onbAvatarTitle": "자주 묻는 질문", - "onbAvatarBody": "펑크를 선택하거나 자신의 사진을 업로드하십시오. 언제든지 변경할 수 있습니다.", + "onbAvatarBody": "아바타를 선택하거나 사진을 업로드하세요. 언제든지 변경할 수 있습니다.", "onbUpload": "사진 업로드", - "onbUploadFailed": "업로드 실패, 지금 punk 시도", + "onbUploadFailed": "업로드하지 못했습니다. 다른 이미지를 사용해 보세요", "onbProfileSkipped": "프로필을 게시 할 수 없습니다 - 나중에 편집 프로필에서 설정", "onbContinue": "계속하기", "onbSkipAvatar": "기본 설정", diff --git a/src/locales/pt.json b/src/locales/pt.json index 8c36922..9f5b2ad 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -616,9 +616,9 @@ "onbNameWait": "Preparando as coisas...", "onbNotNow": "Agora não.", "onbAvatarTitle": "Escolha o seu look", - "onbAvatarBody": "Escolha um punk ou carregue a sua própria foto. Podes mudá-lo quando quiseres.", + "onbAvatarBody": "Escolha um avatar ou carregue a sua própria foto. Podes mudá-lo quando quiseres.", "onbUpload": "Enviar uma foto", - "onbUploadFailed": "O envio falhou, tente um punk por enquanto", + "onbUploadFailed": "O envio falhou. Tente outra imagem", "onbProfileSkipped": "Não foi possível publicar o seu perfil, defini-lo mais tarde a partir de Editar perfil", "onbContinue": "Continuar", "onbSkipAvatar": "Continuar com o meu padrão", diff --git a/src/locales/ru.json b/src/locales/ru.json index 7ea2c0f..4e472fc 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -616,9 +616,9 @@ "onbNameWait": "Готовить вещи...", "onbNotNow": "Не сейчас", "onbAvatarTitle": "Выберите свой взгляд", - "onbAvatarBody": "Выберите панк или загрузите свою фотографию. Вы можете изменить его в любое время.", + "onbAvatarBody": "Выберите аватар или загрузите свою фотографию. Его можно изменить в любое время.", "onbUpload": "Загрузить фото", - "onbUploadFailed": "Загрузить не удалось, попробуйте панк на данный момент", + "onbUploadFailed": "Не удалось загрузить. Попробуйте другое изображение", "onbProfileSkipped": "Не смог опубликовать свой профиль, установите его позже из профиля Edit", "onbContinue": "Продолжать", "onbSkipAvatar": "Продолжить мой дефолт", diff --git a/src/locales/tr.json b/src/locales/tr.json index 1f261b7..e1e9928 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -615,9 +615,9 @@ "onbNameWait": "İşler hazır olsun...", "onbNotNow": "Şimdi Değil", "onbAvatarTitle": "görünümünü seçin", - "onbAvatarBody": "Bir punk seçin veya kendi resmini yükleyin. Her zaman değiştirebilirsiniz.", + "onbAvatarBody": "Bir avatar seçin veya kendi resminizi yükleyin. İstediğiniz zaman değiştirebilirsiniz.", "onbUpload": "Bir fotoğraf", - "onbUploadFailed": "Yükleme başarısız oldu - şimdilik bir punk deneyin", + "onbUploadFailed": "Yükleme başarısız oldu. Başka bir resim deneyin", "onbProfileSkipped": "Profilinizi yayınlayamaz - daha sonra Edit profilinden ayarlayın", "onbContinue": "Devam etmeye devam et", "onbSkipAvatar": "Benim varsayılanimle devam et", diff --git a/src/locales/zh.json b/src/locales/zh.json index 04748fc..6bb4c50 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -616,9 +616,9 @@ "onbNameWait": "准备...", "onbNotNow": "现在不行", "onbAvatarTitle": "选择你的外观", - "onbAvatarBody": "选择一个朋克或上传自己的相片. 你可以随时改变", + "onbAvatarBody": "选择一个头像或上传自己的照片。你可以随时更改", "onbUpload": "上传相片", - "onbUploadFailed": "上传失败,, 暂时尝试一个朋克", + "onbUploadFailed": "上传失败。请尝试其他图片", "onbProfileSkipped": "无法发布您的配置文件, 稍后从编辑配置文件中设置 。", "onbContinue": "继续", "onbSkipAvatar": "继续我的默认", diff --git a/src/media-upload.js b/src/media-upload.js new file mode 100644 index 0000000..686fec5 --- /dev/null +++ b/src/media-upload.js @@ -0,0 +1,86 @@ +import { sha256 } from '@noble/hashes/sha256'; +import { bytesToHex } from '@noble/hashes/utils'; + +// Independent public hosts: a phone upload should not fail just because one +// provider is down or rejects a particular container. nostr.download's NIP-96 +// document currently advertises large image/video/audio uploads; nostr.build +// is the second, widely-used Blossom home. +export const PUBLIC_MEDIA_SERVERS = ['https://nostr.download', 'https://blossom.nostr.build']; + +export class MediaUploadError extends Error { + constructor(code, details = '') { + super(code); + this.name = 'MediaUploadError'; + this.code = code; + this.details = details; + } +} + +async function fileHash(file) { + const hash = sha256.create(); + const chunk = 4 * 1024 * 1024; + for (let at = 0; at < file.size; at += chunk) + hash.update(new Uint8Array(await file.slice(at, at + chunk).arrayBuffer())); + return bytesToHex(hash.digest()); +} + +const responseUrl = (body) => body?.url + || body?.data?.[0]?.url + || (body?.tags || []).find((x) => x?.[0] === 'url')?.[1] + || null; + +function mediaUrl(url, server, file) { + const out = new URL(url, server); + const tail = out.pathname.split('/').at(-1) || ''; + if (/^[0-9a-f]{64}$/i.test(tail)) { + const named = String(file.name || '').match(/\.([a-z0-9]{1,8})$/i)?.[1]; + const byType = { 'video/quicktime': 'mov', 'video/mp4': 'mp4', 'video/webm': 'webm', + 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif', 'image/avif': 'avif' }[file.type]; + const ext = (named || byType || '').toLowerCase(); + if (ext) out.pathname += '.' + ext; + } + return out.href; +} + +// BUD-02 upload. `signEvent` is deliberately injected: the app can use its +// always-available wallet identity, while tests need no secret key. +export async function uploadPublicMedia(file, signEvent, { + servers = PUBLIC_MEDIA_SERVERS, + fetcher = globalThis.fetch, + timeoutMs = 5 * 60_000, +} = {}) { + if (!file || !file.size || typeof signEvent !== 'function') throw new MediaUploadError('failed'); + const x = await fileHash(file); + const now = Math.floor(Date.now() / 1000); + const auth = await signEvent({ + kind: 24242, created_at: now - 5, content: 'upload', + tags: [['t', 'upload'], ['x', x], ['expiration', String(now + Math.ceil(timeoutMs / 1000) + 120)]], + }); + if (!auth) throw new MediaUploadError('failed'); + const authorization = 'Nostr ' + btoa(JSON.stringify(auth)); + const failures = []; + for (const server of servers) { + try { + const response = await fetcher(server.replace(/\/$/, '') + '/upload', { + method: 'PUT', body: file, signal: AbortSignal.timeout(timeoutMs), + headers: { + authorization, + 'content-type': file.type || 'application/octet-stream', + 'x-sha-256': x, + 'x-content-length': String(file.size), + }, + }); + let body = null; + try { body = await response.json(); } catch {} + const url = response.ok && responseUrl(body); + if (url) return mediaUrl(url, server, file); + failures.push({ status: response.status, reason: response.headers.get('x-reason') || body?.message || '' }); + } catch (error) { + failures.push({ status: 0, reason: error?.message || '' }); + } + } + const details = failures.map((x) => [x.status, x.reason].filter(Boolean).join(' ')).filter(Boolean).join('; '); + if (failures.length && failures.every((x) => x.status === 413)) throw new MediaUploadError('too-big', details); + if (failures.length && failures.every((x) => x.status === 415)) throw new MediaUploadError('unsupported', details); + throw new MediaUploadError('failed', details); +} diff --git a/tools/feed-nav-destination-test.js b/tools/feed-nav-destination-test.js new file mode 100644 index 0000000..b14a124 --- /dev/null +++ b/tools/feed-nav-destination-test.js @@ -0,0 +1,18 @@ +// The Feed destination and the new-posts pill deliberately have different +// navigation semantics. Keep this small source-level guard beside the fuller +// browser test for the pill's landing position. +import assert from 'node:assert/strict'; + +const source = await Bun.file('src/features/messages.js').text(); +const navStart = source.indexOf("button('feed', t('feedTitle')"); +const navEnd = source.indexOf("button('messages',", navStart); +const feedButton = source.slice(navStart, navEnd); +assert(navStart > 0 && navEnd > navStart, 'Feed bottom-nav handler exists'); +assert(feedButton.includes('glideToTop()'), 'Feed bottom-nav button returns to the top'); +assert(!feedButton.includes('jumpToNew()'), 'Feed bottom-nav button does not jump to unread posts'); + +const pillStart = source.indexOf("class: 'feed-new-pill'"); +const pill = source.slice(pillStart, source.indexOf('})', pillStart) + 2); +assert(pillStart > 0 && pill.includes('jumpToNew()'), 'new-posts pill jumps to the first unread arrival'); + +console.log('✓ Feed tab goes to the top; only the new-posts pill jumps to unread posts'); diff --git a/tools/media-upload-test.js b/tools/media-upload-test.js new file mode 100644 index 0000000..f9cc56a --- /dev/null +++ b/tools/media-upload-test.js @@ -0,0 +1,43 @@ +import { uploadPublicMedia, MediaUploadError } from '../src/media-upload.js'; +import { sha256 } from '@noble/hashes/sha256'; +import { bytesToHex } from '@noble/hashes/utils'; + +let ok = true; +const check = (name, pass) => { console.log(`${pass ? '✓' : '✗'} ${name}`); if (!pass) ok = false; }; +const bytes = new TextEncoder().encode('phone video bytes'); +const file = new File([bytes], 'clip.mov', { type: 'video/quicktime' }); +let signed = null; +const sign = async (event) => (signed = { ...event, id: '1'.repeat(64), pubkey: '2'.repeat(64), sig: '3'.repeat(128) }); +const calls = []; +const fetcher = async (url, init) => { + calls.push({ url, init }); + if (url.startsWith('https://first')) return new Response(JSON.stringify({ message: 'temporarily unavailable' }), { status: 503 }); + return new Response(JSON.stringify({ url: 'https://cdn.example/clip.mov' }), { status: 201, headers: { 'content-type': 'application/json' } }); +}; + +const url = await uploadPublicMedia(file, sign, { servers: ['https://first', 'https://second/'], fetcher, timeoutMs: 1000 }); +check('falls back to a second independent media host', url === 'https://cdn.example/clip.mov' && calls.length === 2); +check('uploads the original video type and bytes', calls[1].init.method === 'PUT' && calls[1].init.body === file + && calls[1].init.headers['content-type'] === 'video/quicktime'); +check('hashes the file and binds the hash into Blossom auth', calls[1].init.headers['x-sha-256'] === bytesToHex(sha256(bytes)) + && signed.tags.some((tag) => tag[0] === 'x' && tag[1] === calls[1].init.headers['x-sha-256'])); +check('sends signed Blossom authorization', calls[1].init.headers.authorization.startsWith('Nostr ')); + +const bareHashUrl = await uploadPublicMedia(file, sign, { + servers: ['https://media.example'], timeoutMs: 1000, + fetcher: async () => new Response(JSON.stringify({ url: '/' + 'a'.repeat(64) }), { status: 201 }), +}); +check('adds the video extension when a Blossom server returns a bare hash URL', bareHashUrl.endsWith('.mov')); + +try { + await uploadPublicMedia(file, sign, { + servers: ['https://one', 'https://two'], timeoutMs: 1000, + fetcher: async () => new Response('', { status: 413 }), + }); + check('reports provider size rejection', false); +} catch (error) { + check('reports provider size rejection', error instanceof MediaUploadError && error.code === 'too-big'); +} + +console.log(ok ? '\n✅ public media upload works' : '\n❌ public media upload failed'); +process.exit(ok ? 0 : 1); -- cgit v1.2.3 From 75d437e81824865ba21fb9c732b351b75c78a180 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 29 Sep 2026 09:52:00 -0700 Subject: The coinos logo always goes to the wallet home, from the vtxo page and every other page. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit goHome cleared a hand-kept list of pages and the vtxo list (arkCoinsPage) wasn't on it — nor a transaction's detail, the hat shop, the till. It now clears every page the history tracks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp --- src/app.js | 8 ++++++++ 1 file changed, 8 insertions(+) (limited to 'src/app.js') diff --git a/src/app.js b/src/app.js index 203b50a..e9412ec 100644 --- a/src/app.js +++ b/src/app.js @@ -2970,6 +2970,14 @@ function goHome() { ui.addrScan = false; ui.arkMoveOpen = false; ui.nameEditOpen = null; + // ...and every other page the history tracks (the vtxo list, a + // transaction's detail, the hat shop, a lightbox…): home is home, + // whatever was open. The chat's own position is kept for its next visit. + for (const f of NAV_FIELDS) { + if (['screen', 'tab', 'msgView', 'msgCommunity', 'msgPeer', 'feedId'].includes(f)) continue; + ui[f] = ['chatOpen', 'giftMode', 'profEditFilled', 'profOverThread'].includes(f) ? false : null; + } + ui.arkCoinsSel = null; ui.pos = null; ui.feedEdit = null; ui.feedMenu = null; ui.tab = wallet.offline ? 'settings' : 'history'; ui.draft = null; ui.sendResult = null; -- 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