aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-28 21:27:32 -0700
committerAdam Soltys <[email protected]>2026-09-28 21:27:32 -0700
commit292d5527cf36c119d74486061db3a44a608f5138 (patch)
tree303da1796aad613a43798a6784bcb69c5c0796cb
parent356920e8aa7d27dd5e7eff070246ae9ed41498c7 (diff)
downloadcoinosv3-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.js24
-rw-r--r--src/features/messages.js47
-rw-r--r--src/style.css13
-rw-r--r--tools/feed-bottom-nav-test.js62
-rw-r--r--tools/feed-wallet-back-test.js11
5 files changed, 87 insertions, 70 deletions
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: '<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); }