aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/app.js4
-rw-r--r--src/features/ark.js93
-rw-r--r--src/i18n.js1
-rw-r--r--src/style.css3
4 files changed, 63 insertions, 38 deletions
diff --git a/src/app.js b/src/app.js
index 50bf2e6..a67a154 100644
--- a/src/app.js
+++ b/src/app.js
@@ -1960,7 +1960,9 @@ function balanceCard() {
: null,
// "Move money" and friends: the only door between the two balances.
...featureAll('balanceActions').map((a2) =>
- h('button', { class: 'btn-sm', style: 'margin-top:10px', onClick: a2.onClick }, a2.label))
+ h('button', { class: 'btn-sm', style: 'margin-top:10px', onClick: a2.onClick }, a2.label)),
+ // A feature can unfold UI right on the card (ark's inline move panel).
+ featureHook('balanceExtra')
);
}
diff --git a/src/features/ark.js b/src/features/ark.js
index c9ea4e9..d98e92e 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -342,7 +342,9 @@ export function arkFeature(ctx) {
// them. Publishing on connect guarantees every device's ark balance
// reaches the relay (and gets merged) without needing a transaction first.
if (mgr.state && (mgr.state.vtxos || []).length) { try { wallet.saveCache(); } catch {} }
- const tick = () => mgr.sync().catch(() => {}).then(() => driveExits(mgr)).catch(() => {});
+ const tick = () => mgr.sync().catch(() => {})
+ .then(() => driveExits(mgr)).catch(() => {})
+ .then(() => { if (ark === mgr) return maybeAutoRefresh(mgr); }).catch(() => {});
tick();
// Reconcile once on connect: a vtxo synced in from another device (or
// one this device held while a spend happened elsewhere) is checked
@@ -870,10 +872,7 @@ export function arkFeature(ctx) {
const parts = Object.entries(counts).map(([type, n]) => t(LABEL[type] || 'arkPendingActions', { n }));
return parts.length ? h('div', { class: 'small muted' }, parts.join(' · ')) : null;
})(),
- spendables.length >= 1
- ? h('button', { class: 'btn-ghost btn-block', disabled: !!ui.arkBusy, onClick: doArkRefresh },
- ui.arkBusy === 'refresh' ? h('span', { class: 'spinner sm' }) : t('arkRefreshBtn', { n: spendables.length }))
- : null,
+ // Refresh/consolidation is automatic (maybeAutoRefresh) — no button.
// Moving funds out (cooperative offboard / unilateral exit) lives on its
// own page, reached from the "Exit" link on the Ark balance line.
spendables.length >= 1
@@ -1301,15 +1300,29 @@ export function arkFeature(ctx) {
ui.arkBusy = null; render();
}
- async function doArkRefresh() {
- ui.arkBusy = 'refresh'; ui.arkError = ''; render();
+ // VTXO upkeep, invisibly: a send needs a single vtxo covering the amount
+ // (fragmentation breaks coin selection) and every vtxo expires at
+ // expiryHeight (unrenewed, the ASP can eventually sweep it). Rather than
+ // exposing a refresh button, consolidate-and-renew automatically when a
+ // vtxo is inside the renewal window or fragmentation builds up. The round
+ // fee this spends is the cost of keeping the money spendable at all.
+ let arkAutoRefreshAt = 0;
+ async function maybeAutoRefresh(mgr) {
+ if (wallet.watchOnly || !mgr || !mgr.state) return;
+ if (Date.now() - arkAutoRefreshAt < 30 * 60_000) return;
+ const spendables = (mgr.state.vtxos || []).filter((v) => v.state === 'spendable');
+ if (!spendables.length) return;
+ // never start a round while any other action is still in flight
+ if ((mgr.state.actions || []).some((a) => !['done', 'failed'].includes(a.step))) return;
+ arkAutoRefreshAt = Date.now();
try {
- await ark.refresh();
- toast(t('arkRefreshStarted'), 5000);
- } catch (e) {
- ui.arkError = e.message;
- }
- ui.arkBusy = null; render();
+ const tip = await mgr.chain.tipHeight();
+ const RENEW_BLOCKS = getNetwork() === 'regtest' ? 24 : 1008; // ~1 week of margin on mainnet
+ const expiring = spendables.some((v) => v.expiryHeight && v.expiryHeight - tip < RENEW_BLOCKS);
+ const fragmented = spendables.length >= 6;
+ if (!expiring && !fragmented) return;
+ await mgr.refresh();
+ } catch {} // transient — the next throttled attempt retries
}
// Moving money between the two balances, in plain terms. This is the only
@@ -1342,28 +1355,29 @@ export function arkFeature(ctx) {
t('arkBoardNoFunds', { n: minBoard.toLocaleString() })));
}
- function movePage() {
- const b = arkBalance();
- const back = () => { ui.arkMovePage = false; ui.arkError = ''; render(); };
+ // Moving money between the two balances, inline on the balance card — the
+ // one door between the rails opens right where the balances live.
+ function movePanel() {
if (!ark) connectArk().catch(() => {});
- return h('div', { class: 'col', style: 'gap:16px;padding:16px;max-width:460px;margin:0 auto;width:100%' },
- h('div', { class: 'row between' },
- h('h3', { style: 'margin:0' }, t('moveTitle')),
- h('span', { class: 'linklike small', onClick: back }, t('back'))),
- ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null,
- h('div', { class: 'card col', style: 'gap:10px' },
- h('h4', { style: 'margin:0' }, t('moveToSpending')),
- h('p', { class: 'small muted', style: 'margin:0' }, t('moveToSpendingDesc')),
- ark ? boardForm() : h('div', { class: 'row gap6', style: 'align-items:center' },
- h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkConnecting')))),
- h('div', { class: 'card col', style: 'gap:10px' },
- h('h4', { style: 'margin:0' }, t('moveToSaving')),
- h('p', { class: 'small muted', style: 'margin:0' }, t('moveToSavingDesc')),
- h('div', { class: 'small faint' }, t('moveAvailable', { n: fmtAmount(b ? b.spendableSat : 0) + ' ' + unitLabel() })),
- h('button', {
- class: 'btn-block', disabled: !b || !b.spendableSat,
- onClick: () => { ui.arkMovePage = false; ui.arkExitPage = true; ui.arkError = ''; render(); },
- }, t('moveToSavingBtn'))));
+ const dir = ui.arkMoveDir || 'toSpending';
+ const b = arkBalance();
+ return h('div', { class: 'col balance-move' },
+ h('div', { class: 'seg' },
+ h('button', { class: dir === 'toSpending' ? 'active' : '', onClick: () => { ui.arkMoveDir = 'toSpending'; ui.arkError = ''; render(); } }, t('moveToSpending')),
+ h('button', { class: dir === 'toSaving' ? 'active' : '', onClick: () => { ui.arkMoveDir = 'toSaving'; ui.arkError = ''; render(); } }, t('moveToSaving'))),
+ dir === 'toSpending'
+ ? h('div', { class: 'col', style: 'gap:8px' },
+ h('p', { class: 'small muted', style: 'margin:0' }, t('moveToSpendingDesc')),
+ ark ? boardForm() : h('div', { class: 'row gap6', style: 'align-items:center' },
+ h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkConnecting'))))
+ : h('div', { class: 'col', style: 'gap:8px' },
+ h('p', { class: 'small muted', style: 'margin:0' }, t('moveToSavingDesc')),
+ h('div', { class: 'small faint' }, t('moveAvailable', { n: fmtAmount(b ? b.spendableSat : 0) + ' ' + unitLabel() })),
+ h('button', {
+ class: 'btn-block', disabled: !b || !b.spendableSat,
+ onClick: () => { ui.arkMoveOpen = false; ui.arkExitPage = true; ui.arkError = ''; render(); },
+ }, t('moveToSavingBtn'))),
+ ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null);
}
// The Ark receive pane: connect-on-demand, then address + board form.
@@ -1535,7 +1549,7 @@ export function arkFeature(ctx) {
id: 'ark',
init() { initArk(); },
stop() { stopArk(); },
- screenView() { return ui.arkMovePage ? movePage() : ui.arkExitPage ? arkExitPage() : null; },
+ screenView() { return ui.arkExitPage ? arkExitPage() : null; },
receiveModes() {
if (!arkAvailable()) return [];
return [{ id: 'ark', label: t('receiveArkTab'), icon: ARK_MARK(18), render: (seg) => arkReceivePane(seg) }];
@@ -1652,7 +1666,14 @@ export function arkFeature(ctx) {
// "Move money" under the balance — the one door between the two balances.
balanceActions() {
if (wallet.watchOnly || !arkAvailable()) return [];
- return [{ label: t('moveMoney'), onClick: () => { ui.arkMovePage = true; ui.arkError = ''; render(); } }];
+ return [{
+ label: ui.arkMoveOpen ? t('hide') : t('moveMoney'),
+ onClick: () => { ui.arkMoveOpen = !ui.arkMoveOpen; ui.arkError = ''; render(); },
+ }];
+ },
+ balanceExtra() {
+ if (!ui.arkMoveOpen || wallet.watchOnly || !arkAvailable()) return null;
+ return movePanel();
},
spendingSat() {
const b = arkBalance();
diff --git a/src/i18n.js b/src/i18n.js
index 32e7685..55b55d7 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -140,7 +140,6 @@ const STR = {
hiwGiftsTitle: 'Gifts',
hiwGifts1: 'You can gift bitcoin with a link: whoever opens it claims the coins into their own wallet — no app, no signup. On-chain gifts lock the coins until claimed and can be cancelled or reclaimed; Ark gifts claim instantly and free, even into a brand-new empty wallet. A gift can also be locked to a specific nostr account so only they can claim it.',
moveMoney: 'Move money',
- moveTitle: 'Move money',
moveToSpending: 'Into Spending',
moveToSpendingDesc: 'Make money instantly spendable, with no fees to send it.',
moveToSaving: 'Into Savings',
diff --git a/src/style.css b/src/style.css
index c5fde7c..2afabac 100644
--- a/src/style.css
+++ b/src/style.css
@@ -382,3 +382,6 @@ hr.divider { margin: 16px 0; }
/* Settings: section heading between card groups. */
.settings-section { margin: 8px 0 -6px 4px; font-size: 15px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
+
+/* Inline move-money panel on the balance card. */
+.balance-move { gap: 10px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--line); text-align: left; }