aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-02 19:57:46 -0700
committerAdam Soltys <[email protected]>2026-09-02 19:57:57 -0700
commit52b411947bd6c61cf50c60a27249abeedc26c102 (patch)
tree413434542222d4f1af2443b45c461604340823e4
parent1326188e0ffe08398943da2ef051b5553427d1a9 (diff)
downloadcoinosv3-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.js114
-rw-r--r--src/features/ark.js3
-rw-r--r--src/features/gifts.js5
-rw-r--r--src/features/messages.js16
-rw-r--r--src/style.css2
5 files changed, 109 insertions, 31 deletions
diff --git a/src/app.js b/src/app.js
index 4adf45c..a8bcd9f 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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; }