diff options
| author | Adam Soltys <[email protected]> | 2026-09-02 19:57:46 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-02 19:57:57 -0700 |
| commit | 52b411947bd6c61cf50c60a27249abeedc26c102 (patch) | |
| tree | 413434542222d4f1af2443b45c461604340823e4 | |
| parent | 1326188e0ffe08398943da2ef051b5553427d1a9 (diff) | |
| download | coinosv3-52b411947bd6c61cf50c60a27249abeedc26c102.tar.gz coinosv3-52b411947bd6c61cf50c60a27249abeedc26c102.zip | |
History becomes home: the tab bar slims to Receive | Send (tapping the lit one puts it away), every landing — login, account creation, an account snapped into the carousel — is the account's own timeline, and that timeline is now SCOPED: Savings shows the on-chain ledger and gift reservations, Spending shows the ark side. The carousel meanwhile learns mouse and mind-reading: desktop gets a real grab-and-pull drag (snap lifted mid-drag, restored on the glide), the peeking neighbor switches on a single click, and the strip wears grab cursors. And a thread's avatars and author names finally go somewhere: profiles stack on top of the conversation and back returns to it
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MrkLsNScXe9b6BviUSdmMr
| -rw-r--r-- | src/app.js | 114 | ||||
| -rw-r--r-- | src/features/ark.js | 3 | ||||
| -rw-r--r-- | src/features/gifts.js | 5 | ||||
| -rw-r--r-- | src/features/messages.js | 16 | ||||
| -rw-r--r-- | src/style.css | 2 |
5 files changed, 109 insertions, 31 deletions
@@ -538,7 +538,7 @@ wallet.subscribe(scheduleRender); // views — most Spending payments open one of these, and leaving them out // meant the phone's native Back skipped past the history list entirely // (it landed on whatever tab minted the previous entry). -const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'arkMoveDetail', 'arkExitDetail', 'giftDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'msgCommunity', 'msgPeer', 'profilePk', 'settingsPage', 'nameEditOpen', 'noteThread', 'userSearch', 'zapSetup', 'hatShop']; +const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'arkMoveDetail', 'arkExitDetail', 'giftDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'msgCommunity', 'msgPeer', 'profilePk', 'profOverThread', 'settingsPage', 'nameEditOpen', 'noteThread', 'userSearch', 'zapSetup', 'hatShop']; function navSnapshot() { const s = {}; for (const f of NAV_FIELDS) s[f] = ui[f] ?? null; @@ -1181,7 +1181,7 @@ async function activateAccount(acc, opts = {}) { // Restore the tab / open tx from the last session so a refresh keeps the // user's place. A gift link always opens the claim screen instead. const nav = (() => { try { return JSON.parse(sessionStorage.getItem(NAV_KEY) || 'null'); } catch { return null; } })(); - ui.tab = (!opts.gift && nav && nav.tab) || 'receive'; + ui.tab = (!opts.gift && nav && nav.tab) || 'history'; ui.txDetail = (!opts.gift && nav && nav.txDetail) || null; // Not baselined yet — stays null until the scan + ack logic below sets it, // so the celebration never fires for payments that were already there at @@ -1707,7 +1707,7 @@ function enterOfflineFallback() { async function retryOnline() { ui.offlineFallback = false; wallet.setOffline(false); - ui.tab = 'receive'; + ui.tab = 'history'; render(); try { await wallet.scan(); @@ -2150,7 +2150,7 @@ function settingsTab() { tile('notifications', t('settingsNotifications'), t('settingsNotifDesc')), tile('advanced', t('advancedSettings'), t('settingsAdvancedDesc')), ]), - h('button', { class: 'btn-ghost btn-block', style: 'margin-top:6px', onClick: () => { ui.tab = wallet.offline ? 'settings' : 'receive'; render(); } }, t('back')) + h('button', { class: 'btn-ghost btn-block', style: 'margin-top:6px', onClick: () => { ui.tab = wallet.offline ? 'settings' : 'history'; render(); } }, t('back')) ); } @@ -2328,7 +2328,7 @@ function goHome() { ui.addrScan = false; ui.arkMoveOpen = false; ui.nameEditOpen = null; - ui.tab = wallet.offline ? 'settings' : 'receive'; + ui.tab = wallet.offline ? 'settings' : 'history'; ui.draft = null; ui.sendResult = null; ui.sendError = ''; @@ -2782,7 +2782,7 @@ function onboardScreen() { // Spending stays out of sight until the user sets it up. ui.account = 'savings'; try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {} - ui.tab = 'receive'; + ui.tab = 'history'; return null; // the caller falls through to the wallet itself } if (o.step === 'spend') { @@ -3014,7 +3014,7 @@ function onboardScreen() { ui.account = 'spending'; try { localStorage.setItem(ACCOUNT_KEY, 'spending'); } catch {} } - ui.tab = 'receive'; + ui.tab = 'history'; render(); } }, t('onbEnter')), h('button', { class: 'btn-ghost btn-block', onClick: () => { o.step = 'avatar'; render(); } }, t('back')), @@ -3113,7 +3113,7 @@ function walletScreen() { // Tab changes slide the content pane sideways like a carousel — swipe // direction when a gesture caused it, index order for taps. The balance // card and tab strip stay planted. - const TAB_ORDER = ['receive', 'send', 'history']; + const TAB_ORDER = ['history', 'receive', 'send']; if (uiChanged('tabnav', ui.tab)) { const idx = TAB_ORDER.indexOf(ui.tab); // The pane slides only BETWEEN tabs the user has seen: the first @@ -3219,7 +3219,7 @@ function viewLabel(a, view) { function switchToView(id, view) { ui.account = view; try { localStorage.setItem(ACCOUNT_KEY, view); } catch {} - if (id === activeId) { ui.screen = 'wallet'; render(); } + if (id === activeId) { ui.screen = 'wallet'; ui.tab = 'history'; render(); } else { const acc = accounts.find((a) => a.id === id); if (acc) switchAccount(id); @@ -3244,12 +3244,11 @@ function setAccountSel(a, dir) { _accDir = dir || (a === 'savings' ? 'left' : 'right'); ui.account = a; try { localStorage.setItem(ACCOUNT_KEY, a); } catch {} - // A payment's detail page belongs to the account it was opened from — - // switching accounts closes it and lands on Receive. - if (ui.txDetail || ui.arkMoveDetail || ui.arkExitDetail || ui.giftDetail) { - ui.txDetail = null; ui.arkMoveDetail = null; ui.arkExitDetail = null; ui.giftDetail = null; - ui.tab = 'receive'; - } + // Selecting an account shows its history right away — home base — and any + // open payment detail (which belongs to the account it was opened from) + // closes with it. + ui.txDetail = null; ui.arkMoveDetail = null; ui.arkExitDetail = null; ui.giftDetail = null; + ui.tab = 'history'; render(); } @@ -3376,6 +3375,45 @@ function balanceCard() { _accDir = null; // the drag is the animation here return h('div', { class: 'bal-carousel', + // Desktop: scroll-snap has no mouse drag, so emulate one — grab the + // strip and pull. Touch keeps the native pan (pointerType check); + // buttons keep their clicks untouched. Mandatory snap fights raw + // scrollLeft writes, so it's lifted for the drag and restored once the + // release glide lands on a card. + onPointerDown: (e) => { + if (e.pointerType !== 'mouse' || e.button !== 0) return; + if (e.target.closest && e.target.closest('button, a, input')) return; + e.preventDefault(); // no text selection while pulling + const el = e.currentTarget; + const x0 = e.clientX, s0 = el.scrollLeft; + let moved = false; + const mv = (ev) => { + const dx = ev.clientX - x0; + if (!moved && Math.abs(dx) > 4) { + moved = true; + el.classList.add('grabbing'); + el.style.scrollSnapType = 'none'; + } + if (moved) { el.scrollLeft = s0 - dx; el._lastScroll = Date.now(); } + }; + const up = () => { + window.removeEventListener('pointermove', mv); + window.removeEventListener('pointerup', up); + el.classList.remove('grabbing'); + if (!moved) return; + el._dragged = Date.now(); // the release's click must not double as a card tap + const mid = el.scrollLeft + el.clientWidth / 2; + let best = null, bestD = Infinity; + [...el.children].forEach((k) => { + const d = Math.abs(k.offsetLeft + k.offsetWidth / 2 - mid); + if (d < bestD) { bestD = d; best = k; } + }); + if (best) el.scrollTo({ left: best.offsetLeft + best.offsetWidth / 2 - el.clientWidth / 2, behavior: 'smooth' }); + setTimeout(() => { el.style.scrollSnapType = ''; }, 400); + }; + window.addEventListener('pointermove', mv); + window.addEventListener('pointerup', up); + }, onScroll: (e) => { const el = e.target; el._lastScroll = Date.now(); @@ -3391,12 +3429,30 @@ function balanceCard() { if (view && view !== accountSel()) { ui.account = view; try { localStorage.setItem(ACCOUNT_KEY, view); } catch {} + // selecting an account lands on its history (home), any open + // payment detail belonged to the other account + ui.txDetail = null; ui.arkMoveDetail = null; ui.arkExitDetail = null; ui.giftDetail = null; + ui.tab = 'history'; render(); } }, 120); }, - }, ORDER2.map((v) => h('div', { class: 'card balance bal-slide' }, - faceFor(v), ...(v === sel ? cardExtras() : [])))); + }, ORDER2.map((v) => h('div', { + class: 'card balance bal-slide', + // Tapping the peeking neighbor switches to it outright — the post-render + // alignment then glides it into place. Guarded against a desktop drag's + // release click and against buttons riding on the card. + onClick: v === sel ? undefined : (e) => { + if (e.target.closest && e.target.closest('button, a, input')) return; + const strip = e.currentTarget.parentNode; + if (strip && Date.now() - (strip._dragged || 0) < 300) return; + ui.account = v; + try { localStorage.setItem(ACCOUNT_KEY, v); } catch {} + ui.txDetail = null; ui.arkMoveDetail = null; ui.arkExitDetail = null; ui.giftDetail = null; + ui.tab = 'history'; + render(); + }, + }, faceFor(v), ...(v === sel ? cardExtras() : [])))); } const dtRaw = animWindow('acct', sel, 300); // Decide AT the change whether this face switch animates: only deliberate @@ -3413,19 +3469,20 @@ function balanceCard() { function tabsBar() { - // Settings lives in the avatar menu, not the tab strip. + // Settings lives in the avatar menu, not the tab strip. History isn't a tab + // at all any more — it's the wallet's HOME, under the two tabs: tapping the + // lit tab again (or native back) puts it away and the history is what's left. const tabs = [ ['receive', t('tabReceive')], // Watch-only wallets show Send too — it prompts to load the seed to spend. ['send', t('tabSend')], - ['history', t('tabHistory')], ]; return h( 'div', { class: 'tabs' }, tabs.map(([id, label]) => tabBtn(label, ui.tab === id, () => { - ui.tab = id; + ui.tab = ui.tab === id ? 'history' : id; ui.sendError = ''; // a stale send error never survives a tab change ui.revealShown = false; // re-mask the recovery phrase whenever tabs change ui.nostrExportStep = false; // and the exported nostr key @@ -4358,26 +4415,29 @@ function pager(page, total, onPage) { function historyTab() { if (ui.bump) return bumpView(); - const txs = wallet.history; // BIP84 txs + silent-payment receipts, newest first + // History is SCOPED to the selected account: Savings shows the on-chain + // ledger (BIP84 txs, silent-payment receipts, gift reservations), Spending + // shows the ark side (receives, sends, boards, LN) — each card of the + // carousel owns its own timeline. + const histSel = accountSel(); + const txs = histSel === 'spending' ? [] : wallet.history; // newest first const featDetail = featureHook('historyDetail'); if (featDetail) return featDetail; if (ui.txDetail) { - const tx = txs.find((x) => x.txid === ui.txDetail); + const tx = wallet.history.find((x) => x.txid === ui.txDetail); if (tx) return txDetailView(tx); ui.txDetail = null; } if (wallet.offline) return h('div', { class: 'card' }, h('p', { class: 'muted center', style: 'margin:0' }, t('historyOffline'))); - if (!wallet.loaded || (wallet.historyLoading && !txs.length)) + if (!wallet.loaded || (histSel !== 'spending' && wallet.historyLoading && !txs.length)) return h( 'div', { class: 'card center col', style: 'align-items:center;gap:10px' }, h('span', { class: 'spinner' }), wallet.historyLoading ? h('p', { class: 'small muted', style: 'margin:0' }, t('loadingHistory')) : null ); - // Feature movements (Ark receives/sends/boards, ...) interleave with - // on-chain txs by time. - const featEntries = featureAll('historyEntries', txs); + const featEntries = featureAll('historyEntries', txs, histSel); if (!txs.length && !featEntries.length) return h('div', { class: 'card' }, h('p', { class: 'muted center', style: 'margin:0' }, t('noTxYet'))); @@ -4599,7 +4659,7 @@ applyDir(); // Horizontal-dominant, quick, and only on the wallet's own tab pages — never // inside chat, profiles, takeovers, or the (offline-forced) settings tab. (() => { - const ORDER = ['receive', 'send', 'history']; + const ORDER = ['history', 'receive', 'send']; let sx = 0, sy = 0, t0 = 0, live = false, onBalance = false, swallowClick = false; window.addEventListener('click', (e) => { if (!swallowClick) return; diff --git a/src/features/ark.js b/src/features/ark.js index c3a8a8c..1145983 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -2920,7 +2920,8 @@ export function arkFeature(ctx) { render(); } }, t('arkExitInFlightLink', { n: fmtAmount(acts.reduce((s2, a) => s2 + a.amountSat, 0)) })); }, - historyEntries() { + historyEntries(_txs, sel) { + if (sel && sel !== 'spending') return []; // ark movements are the Spending timeline const s = arkStateNow(); if (!s) return []; // Memoized briefly: this rebuilds, sorts, and dedupes the whole diff --git a/src/features/gifts.js b/src/features/gifts.js index a73bfe9..12dd735 100644 --- a/src/features/gifts.js +++ b/src/features/gifts.js @@ -895,7 +895,10 @@ export function giftsFeature(ctx) { const locked = wallet.giftLockedValue(); return locked > 0 ? [{ label: t('lockedInGifts'), sat: locked }] : []; }, - historyEntries(txs) { + historyEntries(txs, sel) { + // These are ON-CHAIN gift reservations — Savings timeline. Ark gifts show + // up as ark send movements on the Spending side. + if (sel === 'spending') return []; // Outstanding sent gifts (reserved/reclaimed but unclaimed) sit above the // on-chain history; they aren't transactions until claimed or revoked. // A claimed gift merges into its claim transaction's row once we know which diff --git a/src/features/messages.js b/src/features/messages.js index 1daa5bc..ac43e40 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1444,6 +1444,11 @@ export function messagesFeature(ctx) { function openProfile(pk) { ui.profilePk = pk; + // Opened from inside a thread (an author's avatar/name), the profile + // stacks ON TOP of it — back returns to the conversation. The screen + // router otherwise keeps a thread above the profile, which is the order + // for the other direction (a note row tapped on a profile page). + ui.profOverThread = !!ui.noteThread; ui.profEdit = null; ui.profEditFilled = false; ui.logoutConfirm = null; render(); fetchFullProfile(pk); @@ -1599,7 +1604,11 @@ export function messagesFeature(ctx) { h('div', { class: 'col grow', style: 'min-width:0;gap:3px' }, h('div', { class: 'row between', style: 'align-items:center;gap:8px' }, h('div', { class: 'row', style: 'gap:7px;align-items:baseline;min-width:0' }, - h('span', { style: 'font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis' }, name), + h('span', { + // the name, like the avatar, is its own tap-target (profile) + style: 'font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer', + onClick: (e) => { e.stopPropagation(); openProfile(pk); }, + }, name), h('span', { class: 'small faint', style: 'white-space:nowrap' }, (isReply ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000))), canZap ? h('button', { class: 'btn-sm', title: t('zapTitle'), onClick: (e) => { e.stopPropagation(); zapNote(pk, ev); } }, '⚡') : null), @@ -1643,6 +1652,7 @@ export function messagesFeature(ctx) { } function openNoteThread(ev) { ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev }; + ui.profOverThread = false; // a freshly opened thread goes on top render(); } // A nostr:nevent / nostr:note reference: the thread loader needs the real @@ -2042,7 +2052,7 @@ export function messagesFeature(ctx) { noteRow(pk, ev, name), ]))); })(), - h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.profilePk = null; ui.pubProf = null; ui.profEdit = null; ui.profEditFilled = false; render(); } }, t('back')), + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.profilePk = null; ui.profOverThread = false; ui.pubProf = null; ui.profEdit = null; ui.profEditFilled = false; render(); } }, t('back')), mine ? logoutPop() : null); } @@ -2756,12 +2766,14 @@ export function messagesFeature(ctx) { // threads reachable from it, are public nostr content — shown // without an account. Back falls through to the app's own screens. if (ui.pubProf) { + if (ui.profOverThread && ui.profilePk) return profileScreen(); if (ui.noteThread) return threadScreen(); if (ui.profilePk) return profileScreen(); } return null; } if (ui.zapSetup) return zapSetupScreen(); + if (ui.profOverThread && ui.profilePk) return profileScreen(); if (ui.noteThread) return threadScreen(); if (ui.profilePk) return profileScreen(); if (ui.userSearch && !ui.chatOpen) return userSearchScreen(); diff --git a/src/style.css b/src/style.css index e21835b..b88fede 100644 --- a/src/style.css +++ b/src/style.css @@ -456,6 +456,8 @@ hr.divider { margin: 16px 0; } .bal-carousel::-webkit-scrollbar { display: none; } .bal-slide { flex: 0 0 calc(100% - 40px); scroll-snap-align: center; min-width: 0; } .bal-carousel .card + .card { margin-top: 0; } +.bal-carousel { cursor: grab; } /* touch never shows a cursor — no media-query hedging */ +.bal-carousel.grabbing, .bal-carousel.grabbing * { cursor: grabbing; user-select: none; } .balance-move .seg { display: flex; width: 100%; } .balance-move .seg button { flex: 1; } .send-suggest { max-height: 260px; overflow-y: auto; } |
