aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.js65
-rw-r--r--src/features/messages.js35
-rw-r--r--src/style.css53
3 files changed, 141 insertions, 12 deletions
diff --git a/src/app.js b/src/app.js
index 69425be..bf49dd1 100644
--- a/src/app.js
+++ b/src/app.js
@@ -790,9 +790,10 @@ function renderInner() {
// because nothing else moves. Onboarding keeps its precedence.
const lockAsk = ui.pw && ui.pw.purpose === 'lock' && !(ui.onb || onbInProgress());
sendPageRendered = false;
+ const featScreen = !lockAsk && featureHook('screenView');
const screen =
(lockAsk && h('div', { class: 'col', style: 'gap:16px' }, brandHeader(false), pwPromptCard()))
- || featureHook('screenView')
+ || featScreen
|| (ui.screen === 'wallet'
? walletScreen()
: ui.screen === 'accounts'
@@ -835,7 +836,9 @@ function renderInner() {
// 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()] : [])]);
+ // the wallet beside the feed and chats on a wide screen (its own page needs no twin)
+ const side = bottomNav && featScreen && WIDE_MQ && WIDE_MQ.matches ? walletSide() : null;
+ morphChildren(root, [screen, footer(), ...(bottomNav ? [bottomNav] : []), ...(side ? [side] : []), ...(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 || ui.mediaPager)); } catch {}
if (fpath) {
@@ -2575,7 +2578,7 @@ function avatarMenu() {
html: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
});
return h('button', {
- class: 'header-avatar',
+ class: 'header-avatar' + (me ? ' header-railed' : ''),
title: me ? t('profYourProfile') : t('walletSettings'),
'aria-label': me ? t('profYourProfile') : t('walletSettings'),
onClick: () => {
@@ -2588,7 +2591,7 @@ function avatarMenu() {
function settingsBtn() {
return h('button', {
- class: 'header-msgs', title: t('tabSettings'), 'aria-label': t('tabSettings'),
+ class: 'header-msgs' + ' header-railed', title: t('tabSettings'), 'aria-label': t('tabSettings'),
onClick: () => {
clearFeatureNav();
ui.screen = 'wallet'; ui.tab = 'settings'; ui.settingsPage = null; render();
@@ -2672,10 +2675,25 @@ function lockBtn() {
}
// Find anyone on nostr from the header — results open their profile.
+// Wide screens trade the search icon for a field in the header: typing in it
+// opens (and drives) the search page, which then drops its own copy of it.
+function headerSearch() {
+ if (!featureHook('userSearchAvailable')) return null;
+ return h('input', {
+ type: 'text', class: 'header-search', placeholder: t('searchUsers') + '…', 'aria-label': t('searchUsers'),
+ value: (ui.userSearch && ui.userSearch.q) || '', autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
+ onInput: (e) => { clearFeatureNavKeepSearch(); featureHook('userSearchQuery', e.target.value); },
+ });
+}
+function clearFeatureNavKeepSearch() {
+ if (ui.userSearch) return; // already on the search page: just the query moves
+ clearFeatureNav(); ui.screen = 'wallet';
+}
+
function searchBtn() {
if (!featureHook('userSearchAvailable')) return null;
return h('button', {
- class: 'header-msgs',
+ class: 'header-msgs' + ' header-railed',
title: t('searchUsers'),
'aria-label': t('searchUsers'),
onClick: () => {
@@ -2705,6 +2723,7 @@ function brandHeader(withLock) {
withLock
? h('div', { class: 'row gap6', style: 'align-items:center' },
searchBtn(),
+ headerSearch(),
settingsBtn(),
avatarMenu())
: ui.pubProf && !acc ? visitorButtons() : null
@@ -4320,6 +4339,42 @@ function setAccountSel(a, dir) {
const GEAR_ICON = (n) => `<svg width="${n}" height="${n}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="display:block"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.09a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.09a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`;
const SWAP_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="m16 21 4-4-4-4"/><path d="M20 17H4"/></svg>';
+// Wide screens: the bottom nav is a rail on the left (CSS only) and, from
+// 1220px, the wallet rides in a column on the right of the feed and chats —
+// the balance, Receive and Send, and the latest payments, a glance away.
+const WIDE_MQ = typeof matchMedia === 'function' ? matchMedia('(min-width: 1220px)') : null;
+try { WIDE_MQ && WIDE_MQ.addEventListener('change', () => render()); } catch {}
+function walletSide() {
+ const toWallet = (tab) => { goHome(); ui.tab = tab; render(); };
+ const spending = featureHook('spendingSat') || 0;
+ const saving = wallet.spendable || 0;
+ const sel = accountSel();
+ const txs = sel === 'spending' ? [] : wallet.history || [];
+ const entries = wallet.loaded ? [
+ ...txs.map((tx) => ({ time: tx.confirmed ? (tx.blockTime || 0) * 1000 : Date.now(), render: () => txHistoryItem(tx) })),
+ ...featureAll('historyEntries', txs, sel),
+ ].sort((a, b) => b.time - a.time).slice(0, 6) : [];
+ const rows = entries.map((e) => {
+ const row = e.render();
+ // a row opens its detail on the wallet page: go there first
+ row.addEventListener('click', () => { goHome(); ui.tab = 'history'; }, true);
+ return row;
+ });
+ const line = (label, sat) => h('div', { class: 'row between small' },
+ h('span', { class: 'muted' }, label), h('span', {}, fmtAmount(sat) + ' ' + unitLabel()));
+ return h('aside', { class: 'wallet-side col', 'data-key': 'wallet-side' },
+ h('div', { class: 'card col', style: 'gap:10px' },
+ h('button', { type: 'button', class: 'wallet-side-total', onClick: () => toWallet('history') },
+ h('span', { class: 'small muted' }, t('balance')),
+ h('span', { class: 'wallet-side-amount' }, wallet.loaded ? fmtAmount(spending + saving) + ' ' + unitLabel() : '…')),
+ spendingActive() ? line(t('spendingLabel'), spending) : null,
+ spendingActive() ? line(t('savingLabel'), saving) : null,
+ h('div', { class: 'row gap6' },
+ h('button', { type: 'button', class: 'btn-primary grow', onClick: () => toWallet('receive') }, t('tabReceive')),
+ h('button', { type: 'button', class: 'btn-primary grow', onClick: () => toWallet('send') }, t('tabSend')))),
+ rows.length ? h('div', { class: 'card history-card' }, h('div', { class: 'list' }, ...rows)) : null);
+}
+
function balanceCard() {
// Dim + spin until this network has data (cache, Nostr state, or a scan has
// populated balances once). `scanning` alone misses the window between
diff --git a/src/features/messages.js b/src/features/messages.js
index 759b155..b80c87b 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -10130,8 +10130,9 @@ export function messagesFeature(ctx) {
}
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 me = !visitor && ctx.shownPubkey ? ctx.shownPubkey() : null;
+ const active = ui.userSearch ? 'search' : me && ui.profilePk === me && !ui.noteThread ? 'profile' : ui.noteThread || ui.profilePk ? 'feed' : ui.chatOpen
+ ? ui.msgView === 'feed' ? 'feed' : ui.msgView === 'notifs' ? 'notifs' : 'messages' : ui.tab === 'settings' ? 'settings' : 'wallet';
const leavePage = () => {
stopFeedWatch(); stopNotifWatch();
// Top-level destinations must leave any feature page covering the shell.
@@ -10149,8 +10150,8 @@ export function messagesFeature(ctx) {
return notifUnread();
};
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 = (id, label, paths, onClick) => h('button', {
- type: 'button', class: 'app-nav-button' + (!visitor && (id === 'messages' ? hook('unreadMessages') : id === 'notifs' && notifsWaiting()) ? ' unread' : ''),
+ const button = (id, label, paths, onClick, cls = '') => h('button', {
+ type: 'button', class: 'app-nav-button' + cls + (!visitor && (id === 'messages' ? hook('unreadMessages') : id === 'notifs' && notifsWaiting()) ? ' unread' : ''),
'aria-label': label, 'aria-current': active === id ? 'page' : undefined,
onClick: (e) => {
// An open composer doesn't follow you around: back on the feed it sat
@@ -10160,9 +10161,17 @@ export function messagesFeature(ctx) {
onClick(e);
},
}, h('span', { html: icon(paths) }), h('span', {}, label));
+ // Your profile heads the desktop rail: a bigger face and your name, where
+ // the header's avatar sits on a phone.
+ const profileItem = me ? h('button', {
+ type: 'button', class: 'app-nav-button app-nav-wide app-nav-profile',
+ 'aria-label': t('profYourProfile'), 'aria-current': active === 'profile' ? 'page' : undefined,
+ onClick: () => { ui.profCompose = null; leavePage(); openProfile(me); render(); window.scrollTo({ top: 0 }); },
+ }, avatar(me, 'chat-avatar app-nav-ava', false), h('span', { class: 'app-nav-name' }, displayName(me))) : null;
return h('nav', {
class: 'app-bottom-nav', 'data-key': 'app-bottom-nav',
},
+ profileItem,
button('feed', t('feedTitle'), '<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2m0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/><path d="M18 14h-8M15 18h-5M10 6h8v4h-8z"/>', () => {
// A destination tab is a destination: tapping Feed while already
// there always returns to its top. Only the explicit "x new posts"
@@ -10191,7 +10200,13 @@ export function messagesFeature(ctx) {
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 });
- }));
+ }),
+ // Settings joins the nav where it's the desktop rail (the header's own
+ // gear steps aside there); a phone's bar keeps four.
+ ctx.openSettingsPage ? button('settings', t('tabSettings'), '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.09a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.09a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>', () => {
+ if (visitor) { toFrontDoor(); return; }
+ leavePage(); ctx.openSettingsPage(null);
+ }, ' app-nav-wide') : null);
}
// ---- media views: a feed's pictures as a grid, its videos to flip through --
// All / Images / Videos sits under the feed chips. Images lays the feed's
@@ -11322,9 +11337,9 @@ export function messagesFeature(ctx) {
return h('div', { class: 'col', style: 'gap:16px' },
ctx.brandHeader(!ui.pubProf && wallet.loaded),
h('div', { class: 'card col', style: 'gap:10px' },
- h('h3', { style: 'margin:0' }, t('searchUsers')),
+ h('h3', { class: 'user-search-main', style: 'margin:0' }, t('searchUsers')),
h('input', {
- type: 'text', class: 'user-search-input', placeholder: t('searchUsersHint'), value: s.q,
+ type: 'text', class: 'user-search-input user-search-main', placeholder: t('searchUsersHint'), value: s.q,
autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
onInput: (e) => { s.q = e.target.value; userSearcher.update(s.q); },
}),
@@ -13049,6 +13064,12 @@ export function messagesFeature(ctx) {
},
// The header magnifier: search anyone on nostr, results open profiles.
userSearchAvailable() { return true; },
+ // the desktop header's search field: typing there IS the search page's query
+ userSearchQuery(q) {
+ if (!ui.userSearch) { if (!isVisitor()) ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.userSearch = { q: '', rows: null }; warmSearch(); }
+ ui.userSearch.q = q; userSearcher.update(q); render();
+ return true;
+ },
openUserSearch() {
if (!isVisitor()) ui.chatOpen = false; // a visitor's feed stays underneath, for Back
ui.profilePk = null;
diff --git a/src/style.css b/src/style.css
index db6e00e..df90cbe 100644
--- a/src/style.css
+++ b/src/style.css
@@ -874,6 +874,8 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
}
@media (hover: hover) { .app-nav-button:hover { color: var(--ink); } }
.app-nav-button > span:first-child { display: flex; position: relative; }
+.app-nav-wide { display: none; }
+.header-search { display: none; width: 240px; padding: 8px 14px; border-radius: 999px; font-size: 14px; }
.app-nav-button[aria-current="page"] { background: var(--accent-soft); color: var(--ink); font-weight: 600; }
.app-nav-button.unread > span:first-child::after {
content: ''; position: absolute; top: -2px; right: -4px; width: 7px; height: 7px;
@@ -1519,3 +1521,54 @@ body::after {
position: absolute; left: 6px; bottom: 6px; padding: 1px 6px; border-radius: 6px;
background: rgba(0, 0, 0, .6); color: #fff; font-size: 11px; font-weight: 600; pointer-events: none;
}
+
+/* ---- wide screens ----
+ From 900px the bottom nav becomes a rail on the left of a 600px page
+ column; from 1220px the wallet rides in a column on the right (walletSide
+ in app.js). The fixed floaters (Post, new posts, toasts) sit over the page
+ column: --col-mid is its centre, --col-edge its right edge, both measured
+ from the window's centre (the grid is centred in it). */
+@media (min-width: 900px) {
+ :root { --col-mid: -184px; --col-edge: 416px; }
+ #app:has(> .app-bottom-nav) {
+ max-width: none; display: grid; justify-content: center; align-items: start;
+ grid-template-columns: 200px minmax(0, 600px); grid-template-rows: 1fr auto; column-gap: 32px;
+ }
+ #app:has(> .app-bottom-nav) > * { grid-column: 2; min-width: 0; }
+ #app:has(> .app-bottom-nav) { padding-bottom: 24px; }
+ .app-nav-wide { display: flex; }
+ /* the rail carries your profile and Settings here; the header trades its
+ search icon for a field, which the search page then doesn't repeat */
+ #app:has(> .app-bottom-nav) .header-railed { display: none; }
+ #app:has(> .app-bottom-nav) .header-search { display: block; }
+ #app:has(> .app-bottom-nav) .user-search-main { display: none; }
+ .app-nav-profile { min-height: 64px; margin-bottom: 6px; gap: 12px; }
+ .app-nav-profile .app-nav-ava { width: 44px; height: 44px; flex-shrink: 0; }
+ .app-nav-name { font-weight: 650; color: var(--ink); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ /* an open chat fills the window: its page stretches down the grid row */
+ #app > .col:has(> .chat-card) { align-self: stretch; height: 100%; }
+ #app > .app-bottom-nav {
+ grid-column: 1; grid-row: 1 / span 2; position: sticky; top: 24px; z-index: 5;
+ flex-direction: column; justify-content: flex-start; gap: 4px;
+ padding: 0; background: none; border: 0; box-shadow: none;
+ }
+ .app-nav-button {
+ flex: none; flex-direction: row; justify-content: flex-start; gap: 14px;
+ min-height: 48px; padding: 8px 14px; font-size: 16px;
+ }
+ .feed-post-fab { right: calc(50% - var(--col-edge) + 16px); bottom: 24px; }
+ .feed-new-pill, .feed-page .feed-new-pill { left: calc(50% + var(--col-mid)); bottom: 24px; }
+ .toast { left: calc(50% + var(--col-mid)); }
+}
+@media (min-width: 1220px) {
+ :root { --col-mid: -60px; --col-edge: 240px; }
+ /* three columns on every page with the nav, the wallet's own included,
+ so the page column never jumps sideways between pages */
+ #app:has(> .app-bottom-nav) { grid-template-columns: 200px minmax(0, 600px) 320px; }
+ #app > .wallet-side { grid-column: 3; grid-row: 1 / span 2; position: sticky; top: 24px; gap: 12px; }
+}
+.wallet-side-total {
+ display: flex; flex-direction: column; align-items: flex-start; gap: 2px; width: 100%;
+ padding: 0; border: 0; background: none; color: var(--ink); text-align: left; cursor: pointer;
+}
+.wallet-side-amount { font-size: 24px; font-weight: 700; letter-spacing: -.01em; }