diff options
| author | Adam Soltys <[email protected]> | 2026-09-28 21:27:32 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-28 21:27:32 -0700 |
| commit | 292d5527cf36c119d74486061db3a44a608f5138 (patch) | |
| tree | 303da1796aad613a43798a6784bcb69c5c0796cb | |
| parent | 356920e8aa7d27dd5e7eff070246ae9ed41498c7 (diff) | |
| download | coinosv3-292d5527cf36c119d74486061db3a44a608f5138.tar.gz coinosv3-292d5527cf36c119d74486061db3a44a608f5138.zip | |
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 <[email protected]>
| -rw-r--r-- | src/app.js | 24 | ||||
| -rw-r--r-- | src/features/messages.js | 47 | ||||
| -rw-r--r-- | src/style.css | 13 | ||||
| -rw-r--r-- | tools/feed-bottom-nav-test.js | 62 | ||||
| -rw-r--r-- | tools/feed-wallet-back-test.js | 11 |
5 files changed, 87 insertions, 70 deletions
@@ -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: '<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>', - })); -} - // 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) => '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + paths + '</svg>'; - 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'), '<path d="m3 10 9-7 9 7v10a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1z"/>', jumpToNew), - button(t('msgDmsTitle'), '<path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8z"/>', () => { - if (signinAsk()) return; - stopFeedWatch(); ui.msgView = 'home'; render(); + button('feed', t('feedTitle'), '<path d="m3 10 9-7 9 7v10a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1z"/>', () => { + 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'), '<path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8z"/>', () => { + if (visitor) { toFrontDoor(); return; } + leavePage(); ui.chatOpen = true; ui.msgView = 'home'; render(); window.scrollTo({ top: 0 }); }), - button(t('alertsTitle'), '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"/>', () => { - if (signinAsk()) return; - stopFeedWatch(); openNotifs(); + button('notifs', t('alertsTitle'), '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"/>', () => { + if (visitor) { toFrontDoor(); return; } + leavePage(); openNotifs(); window.scrollTo({ top: 0 }); }), - button(t('settingsWallet'), '<path d="M20 8V5H5a2 2 0 0 0 0 4h16v11H5a2 2 0 0 1-2-2V7"/><path d="M21 12h-5v5h5"/><path d="M17 14.5h.01"/>', () => { + button('wallet', t('settingsWallet'), '<path d="M20 8V5H5a2 2 0 0 0 0 4h16v11H5a2 2 0 0 1-2-2V7"/><path d="M21 12h-5v5h5"/><path d="M17 14.5h.01"/>', () => { 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); } |
