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 --- tools/feed-bottom-nav-test.js | 73 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 tools/feed-bottom-nav-test.js (limited to 'tools/feed-bottom-nav-test.js') 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 'tools/feed-bottom-nav-test.js') 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 'tools/feed-bottom-nav-test.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 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 'tools/feed-bottom-nav-test.js') 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