diff options
| author | Adam Soltys <[email protected]> | 2026-07-04 09:05:23 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-04 09:05:23 -0700 |
| commit | 5fb48ee5284dbba34c8fa3b77c36379f49404ac2 (patch) | |
| tree | 10e0ddb60968ca3f4f9a88e75d4a990e8cc34cb8 /src/app.js | |
| parent | 11a14600da683769c74d49e3d973183aa1a2fe5e (diff) | |
| download | coinosv3-5fb48ee5284dbba34c8fa3b77c36379f49404ac2.tar.gz coinosv3-5fb48ee5284dbba34c8fa3b77c36379f49404ac2.zip | |
Features: extract gifts behind the plugin seam
The whole gift surface moves to src/features/gifts.js (~800 lines):
create/split/lock flows, link viewing, revoke/reclaim, history rows +
detail page, claim-tx row decoration and tx-detail section, the claim
flow (fresh or existing wallet, nostr-locked gifts, claim codes, NIP-07
extension), the boot URL handling (/g/, /lg/, #gift=), the post-claim
celebration, and the locked-in-gifts balance line.
New seams this required: screenView (full-screen feature views),
bootUrl (URL takeover at startup), giftOpened (claim pre-check on
wallet activation), sendFormExtras, decorateTxRow, and multi-section
txDetailSection. Account access goes through explicit ctx accessors
(getAccounts/setAccounts) — the moved code briefly relied on an
accidental cross-module binding that broke activeAccount() during
claims; caught by the gift e2e.
app.js: 4,206 → 3,000 lines. Ark + gift browser suites pass.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QFAcD4V284DvYZZHP7Rxxy
Diffstat (limited to 'src/app.js')
| -rw-r--r-- | src/app.js | 797 |
1 files changed, 48 insertions, 749 deletions
@@ -4,12 +4,13 @@ // which rebuilds the active screen. Text inputs write back into `ui` on `input` // (without re-rendering) so their values survive structural re-renders. -import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxoId, previewGift, giftOutpoints, buildClaimTx, giftMinimum, parseExtendedKey, xpubToZpub, encryptVault, decryptVault, lockGift, previewLockedGift } from './wallet.js'; +import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxoId, parseExtendedKey, xpubToZpub, encryptVault, decryptVault } from './wallet.js'; import { qrSvg } from './qr.js'; import { scanQr } from './scan.js'; import { getSyncConfig, setSyncConfig, parseNostrPubkey, npubOf, fetchNostrProfile, decryptWithCode } from './nostr.js'; import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js'; import { swapsFeature } from './features/swaps.js'; +import { giftsFeature } from './features/gifts.js'; import { arkFeature } from './features/ark.js'; import { spFeature } from './features/sp.js'; import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js'; @@ -284,13 +285,8 @@ function render() { try { selStart = a.selectionStart; selEnd = a.selectionEnd; } catch {} } const screen = - ui.claimLocked - ? lockedGiftClaimView() - : ui.viewGift - ? viewGiftView() - : ui.claimChoose - ? claimChooseView() - : ui.screen === 'wallet' + featureHook('screenView') + || (ui.screen === 'wallet' ? walletScreen() : ui.screen === 'accounts' ? accountsScreen() @@ -298,11 +294,9 @@ function render() { ? accountSettingsScreen() : ui.screen === 'vault' ? vaultScreen() - : ui.screen === 'claim' - ? claimScreen() : ui.screen === 'howItWorks' ? howItWorksScreen() - : unlockScreen(); + : unlockScreen()); root.replaceChildren(screen, footer()); if (fpath) { const el = nodeAtPath(fpath); @@ -853,9 +847,9 @@ async function activateAccount(acc, opts = {}) { // discovery scan. The cache/Nostr state is already on screen. wallet.startRealtime(); - // Opened a gift link? Verify its funding coin is still unspent before letting - // the claim proceed — so a second opener can't try to race/double-spend. - if (opts.gift) checkGiftClaimed(opts.gift); + // Opened a gift link? Let the gifts feature verify its funding coin is + // still unspent before the claim proceeds. + if (opts.gift) featureHook('giftOpened', opts.gift); // Cross-device state in the background. State comes from the local cache or // Nostr — both restore the full balance, coins, and history — so a full API @@ -1495,239 +1489,18 @@ async function doConsolidate() { } -// Gift link: presign a chosen amount as a #gift= PSBT that whoever opens claims -// into a fresh wallet only they control. The coin is reserved until claimed. -function giftUrl() { - return `${location.origin}/${ui.giftLocked ? 'lg' : 'g'}/${ui.giftCode}`; -} -function giftRate() { - return (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; -} -function createGiftLink() { - const rate = giftRate(); - if (ui.giftMax) { doCreateGiftAll(rate); return; } - const min = giftMinimum(rate); - const amt = parseAmount(ui.giftAmount, unit); // entered in the current display unit - if (!amt || amt < min) { ui.giftError = t('giftAmountInvalid', { n: fmtAmount(min) + ' ' + unitLabel() }); render(); return; } - const spendable = wallet.spendable; - if (amt > spendable) { ui.giftError = t('giftExceedsBalance'); render(); return; } - // A specific-amount gift must leave us a dust change output (the gift PSBT - // commits one). If the amount is so close to the balance that no dust change - // is possible, point the user at Max to gift the whole balance instead. - const summary = wallet.giftCoinSummary(amt); - if (summary == null) { - ui.giftError = t('giftNeedsHeadroom', { n: fmtAmount(Math.max(0, spendable - 294)) + ' ' + unitLabel() }); - render(); - return; - } - const lock = summary.lock; - // Offer to split/consolidate into one coin first when it would free meaningful - // change OR when the gift would otherwise bundle several coins — a many-input - // gift is costly to claim (each input adds to the claimer's fee) and its link - // can outgrow a QR. Splitting absorbs the fragmentation into the funding tx and - // leaves the claimer a clean single-input gift. - const splitFee = Math.ceil((11 + 68 + 31 * 2) * Math.max(1, Math.round(rate))); - const freed = lock - amt - 294; - if ((freed > splitFee || summary.count >= 3) && !wallet.offline) { - ui.giftSplitOffer = { amt, lock, freed: Math.max(0, freed), fee: splitFee, count: summary.count }; - ui.giftError = ''; - render(); - return; - } - doCreateGift(amt, rate); -} -// Finalize a freshly-built gift. With a recipient npub it's a locked gift: the -// claim payload is encrypted under a one-time code, the link carries only the -// ciphertext, and the code is DM'd to the recipient over nostr. Without, it's a -// plain bearer gift. -function finishGift(g, fallbackAmt) { - const amount = g.amount || fallbackAmt || 0; - const pk = ui.giftLockNpub.trim() ? parseNostrPubkey(ui.giftLockNpub) : null; - if (!pk) { - ui.giftCode = g.code; ui.giftLocked = false; ui.giftClaimCode = null; ui.giftDmStatus = null; - wallet.recordGift({ code: g.code, locked: false, amount, outpoints: g.reserved }); - return; - } - const { blob, claimCode } = lockGift(g.code, amount, pk); - ui.giftCode = blob; ui.giftLocked = true; ui.giftClaimCode = claimCode; ui.giftDmStatus = 'sending'; - wallet.recordGift({ code: blob, locked: true, amount, claimCode, outpoints: g.reserved }); - const dmText = t('giftDmText', { amount: fmtAmount(amount) + ' ' + unitLabel(), link: giftUrl(), code: claimCode }); - wallet.sendNostrDM(pk, dmText).then((ok) => { ui.giftDmStatus = ok ? 'sent' : 'failed'; render(); }).catch(() => { ui.giftDmStatus = 'failed'; render(); }); -} -function doCreateGift(amt, rate) { - try { - finishGift(wallet.createGift(amt, rate), amt); - ui.giftError = ''; - ui.giftSplitOffer = null; - } catch (e) { - ui.giftError = e.message; - ui.giftSplitOffer = null; - } - render(); -} -// Gift the whole spendable balance as a no-change sweep (the recipient receives -// everything minus their claim fee). -function doCreateGiftAll(rate) { - try { - finishGift(wallet.createGiftAll(rate)); - ui.giftError = ''; - ui.giftMax = false; - ui.giftSplitOffer = null; - } catch (e) { - ui.giftError = e.message; - } - render(); -} -// Split + gift in one tap: broadcast a self-send carving out a right-sized -// coin, then immediately build the gift from that (unconfirmed) carve-out, so -// only ~the gift amount is locked and there's no confirmation wait. The link is -// ready right away; the recipient sees a pending balance until the split lands. -async function doSplitForGift(amt) { - if (wallet.offline) { ui.giftError = t('scanOffline'); render(); return; } - ui.busy = true; ui.giftError = ''; render(); - try { - const rate = (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; - finishGift(await wallet.createGiftFromSplit(amt, rate), amt); - ui.giftSplitOffer = null; - ui.giftError = ''; - } catch (e) { - ui.giftError = e.message || t('giftSplitFailed'); - } - ui.busy = false; - wallet.scan().catch(() => {}); // reconcile the split from the mempool - render(); -} -function giftCard() { - const active = wallet.outstandingGifts(); - return h( - 'div', - { class: 'card col' }, - h('h3', {}, t('giftLink')), - ui.giftCode - ? h('div', { class: 'col', style: 'align-items:center;gap:10px' }, - ui.giftLocked && (() => { - const lk = previewLockedGift(ui.giftCode); - if (!lk) return null; - return h('div', { class: 'col gap6', style: 'width:100%;align-items:center' }, - h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'small muted' }, t('giftLockedTo')), profileChip(lk.to)), - ui.giftDmStatus === 'sent' ? h('div', { class: 'small', style: 'color:var(--ok,#2a8)' }, t('giftDmSent')) - : ui.giftDmStatus === 'failed' ? h('div', { class: 'col gap6', style: 'align-items:center' }, h('div', { class: 'small err' }, t('giftDmFailed')), ui.giftClaimCode ? copyBtn(ui.giftClaimCode, t('giftCopyCode')) : null) - : h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('giftDmSending')))); - })(), - // A bearer gift uppercases the URL for a smaller alphanumeric QR (the - // base32 code is case-insensitive). A locked gift's payload is - // case-sensitive base64url, so it uses a plain byte-mode QR. A large - // (many-input) gift can exceed QR capacity — then fall back to the link. - (() => { - let svg = null; - try { svg = ui.giftLocked ? qrSvg(giftUrl()) : qrSvg(giftUrl().toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }); } catch {} - return svg ? h('div', { html: svg }) : h('div', { class: 'small faint', style: 'text-align:center;padding:8px' }, t('giftQrTooLong')); - })(), - h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, giftUrl()), - h('div', { class: 'row gap6 wrap' }, - copyBtn(giftUrl(), t('copyLink')), - h('button', { class: 'btn-sm grow', onClick: () => { ui.giftCode = null; ui.giftAmount = ''; ui.giftMax = false; ui.giftLockNpub = ''; ui.giftLocked = false; ui.giftClaimCode = null; ui.giftDmStatus = null; render(); } }, t('giftAnother')) - ) - ) - : ui.giftSplitOffer - ? (() => { - const o = ui.giftSplitOffer; - const u = ' ' + unitLabel(); - const manyCoins = (o.count || 0) >= 3; - return h('div', { class: 'col gap6' }, - h('div', { class: 'small muted' }, - manyCoins - ? t('giftConsolidateExplain', { count: o.count, fee: fmtAmount(o.fee) + u }) - : t('giftSplitExplain', { lock: fmtAmount(o.lock) + u, change: fmtAmount(o.lock - o.amt) + u, fee: fmtAmount(o.fee) + u })), - ui.giftError && h('div', { class: 'notice err' }, ui.giftError), - ui.busy - ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' })) - : h('div', { class: 'col gap6' }, - h('button', { class: 'btn-primary btn-block', onClick: () => doSplitForGift(o.amt) }, manyCoins ? t('giftConsolidateFirst') : t('giftSplitFirst')), - h('button', { class: 'btn-block', onClick: () => doCreateGift(o.amt, giftRate()) }, manyCoins ? t('giftUseManyCoins', { count: o.count }) : t('giftLockWhole', { n: fmtAmount(o.lock) + u })), - h('button', { class: 'linklike small', style: 'align-self:center', onClick: () => { ui.giftSplitOffer = null; render(); } }, t('back')) - ) - ); - })() - : h('div', { class: 'col gap6' }, - h('div', { class: 'input-group' }, - h('input', { type: 'number', step: unit === 'sats' ? '1' : '0.00000001', min: '0', inputmode: 'decimal', placeholder: t('giftAmountLabel'), - disabled: ui.giftMax, - value: ui.giftMax ? (unit === 'sats' ? String(wallet.spendable) : fmtBtc(wallet.spendable)) : ui.giftAmount, - onInput: (e) => (ui.giftAmount = e.target.value) }), - h('button', { type: 'button', class: ui.giftMax ? 'btn-primary' : '', onClick: () => { ui.giftMax = !ui.giftMax; ui.giftError = ''; render(); } }, t('max')), - h('div', { style: 'display:flex;align-items:center' }, unitTag()) - ), - h('div', { class: 'small faint' }, ui.giftMax ? t('giftAllNote') : t('giftMinNote', { n: fmtAmount(giftMinimum(giftRate())) + ' ' + unitLabel() })), - (() => { - const raw = ui.giftLockNpub.trim(); - const pk = raw ? parseNostrPubkey(raw) : null; - return h('div', { class: 'col gap6' }, - h('input', { type: 'text', class: 'mono-input', placeholder: t('giftLockPlaceholder'), - autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: ui.giftLockNpub, - onInput: (e) => { ui.giftLockNpub = e.target.value; render(); } }), - !raw ? h('div', { class: 'small faint' }, t('giftLockHint')) - : pk ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'small muted' }, t('giftLockTo')), profileChip(pk)) - : h('div', { class: 'small err' }, t('giftLockInvalid'))); - })(), - ui.giftError && h('div', { class: 'notice err' }, ui.giftError), - h('button', { class: 'btn-block', onClick: createGiftLink }, t('giftLinkReveal')) - ), - active.length - ? h('div', { class: 'col gap6', style: 'margin-top:4px' }, - h('span', { class: 'small muted' }, t('giftReserved', { n: active.length })), - ...active.map((g) => { - const amt = g.value != null ? fmtAmount(g.value) + ' ' + unitLabel() : g.id.slice(0, 12) + '…'; - const label = amt + (g.reserved ? '' : ' · ' + t('giftReclaimedTag')); - if (ui.revokeId === g.id) { - return h('div', { class: 'col gap6' }, - h('span', { class: 'small muted' }, g.reserved ? t('giftReclaimPrompt') : t('giftRevokeConfirm')), - ui.busy - ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' })) - : h('div', { class: 'row gap6' }, - g.reserved ? h('button', { class: 'btn-ghost grow', onClick: () => doReclaim(g.id) }, t('giftReclaim')) : null, - h('button', { class: 'btn-primary grow', onClick: () => doRevoke(g.id) }, t('giftRevoke')) - ), - ui.busy ? null : h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.revokeId = null; render(); } }, t('back')) - ); - } - return h('div', { class: 'row between' }, - h('span', { class: 'small mono' }, label), - h('button', { class: 'btn-sm', onClick: () => { ui.revokeId = g.id; render(); } }, g.reserved ? t('giftReclaim') : t('giftRevoke')) - ); - }) - ) - : null - ); -} -// Passive reclaim: just free the coin for a future payment (no fee, no -// broadcast). The link stays claimable until the coin is actually spent. -function doReclaim(id) { - wallet.unreserve(id); - ui.revokeId = null; - toast(t('giftReclaimed')); - render(); -} -// Active revoke: spend the coin back now (pays a fee), killing the link. -async function doRevoke(id) { - if (wallet.offline) { toast(t('scanOffline')); return; } - ui.busy = true; - render(); - try { - const rate = (wallet.feeRates && wallet.feeRates.fastestFee) || 10; - await wallet.revokeGift(id, rate); - wallet.markGiftRevoked(id); // we took it back — don't later mislabel it as claimed - ui.revokeId = null; - toast(t('giftRevoked')); - wallet.scan().catch(() => {}); - } catch (e) { - toast(e.message); - } - ui.busy = false; - render(); -} + + + + + + + + + + // Switch the active Bitcoin network. Changes addresses/balances entirely, so we // persist the choice and reload the active account (or empty wallet) under it. @@ -1888,193 +1661,19 @@ function goHome() { render(); } -// ================================================================ GIFT / CLAIM -// Read a gift link, returning the code (and scrubbing it from the URL so the -// bearer instrument doesn't linger in the address bar / history). New links are -// /g/<base32-compact>; legacy links are #gift=<base64url-psbt>. Both validate by -// decoding to a real gift. -function readGiftHash() { - try { - let code = null; - const pm = location.pathname.match(/^\/g\/([A-Za-z0-9]+)\/?$/i); // new path form - if (pm) code = pm[1]; - else { - const hm = location.hash.match(/^#gift=([A-Za-z0-9_-]+)$/); // legacy hash form - if (hm) code = hm[1]; - } - if (!code) return null; - history.replaceState(null, '', '/'); - return previewGift(code) ? code : null; - } catch { - return null; - } -} -// A gift locked to a nostr key: /lg/<base64url>. Returns the public preview -// ({ amount, to, eph, ct }) — claimed by the recipient's own wallet, not a fresh -// one, so we don't auto-create a wallet here. -function readLockedGiftHash() { - try { - const m = location.pathname.match(/^\/lg\/([A-Za-z0-9_-]+)\/?$/); - if (!m) return null; - history.replaceState(null, '', '/'); - return previewLockedGift(m[1]); - } catch { - return null; - } -} -// Re-view a previously created gift's link + QR (from its stored record), so the -// sender can re-share an unclaimed gift. For a locked gift, also surfaces the -// claim code that was DM'd, in case it needs re-sending. -function viewGiftView() { - const g = ui.viewGift; // { code, locked, amount, claimCode } - const url = `${location.origin}/${g.locked ? 'lg' : 'g'}/${g.code}`; - let svg = null; - try { svg = g.locked ? qrSvg(url) : qrSvg(url.toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }); } catch {} - return h('div', { class: 'col', style: 'gap:16px;padding:16px;max-width:460px;margin:0 auto;width:100%' }, - h('div', { class: 'card col', style: 'gap:12px;align-items:center' }, - h('h3', { style: 'margin:0' }, t('giftView')), - g.amount != null ? h('div', { class: 'amt' }, fmtAmount(g.amount), ' ', unitTag()) : null, - svg ? h('div', { html: svg }) : h('div', { class: 'small faint', style: 'text-align:center;padding:8px' }, t('giftQrTooLong')), - h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, url), - copyBtn(url, t('copyLink')), - g.locked && g.claimCode - ? h('div', { class: 'col gap6', style: 'width:100%;align-items:center' }, h('div', { class: 'small muted' }, t('giftCodeLabel')), copyBtn(g.claimCode, t('giftCopyCode'))) - : null, - h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.viewGift = null; render(); } }, t('back')) - ) - ); -} -// A locked gift opened from a link: the amount + recipient are public. The -// recipient pastes the claim code from their nostr DM; that decrypts the payload, -// then it's claimed into a fresh wallet exactly like a normal gift. -function lockedGiftClaimView() { - const lk = ui.claimLocked; - // NIP-07: if a nostr browser extension is present, the recipient can decrypt - // in-place — no code needed. - const hasExt = !!(typeof globalThis !== 'undefined' && globalThis.nostr && lk.eph && lk.ctKey); - return h('div', { class: 'col', style: 'gap:16px;padding:16px;max-width:460px;margin:0 auto;width:100%' }, - h('div', { class: 'card col', style: 'gap:14px;align-items:center' }, - h('h3', { style: 'margin:0' }, t('giftForYou')), - h('div', { class: 'amt' }, fmtAmount(lk.amount), ' ', unitTag()), - h('div', { class: 'row gap6', style: 'align-items:center' }, - h('span', { class: 'small muted' }, t('giftLockedTo')), - profileChip(lk.to, { size: 36 })), - ui.claimError && h('div', { class: 'notice err' }, ui.claimError), - hasExt ? h('button', { class: 'btn-primary btn-block', disabled: ui.busy, onClick: claimViaExtension }, ui.busy ? h('span', { class: 'spinner' }) : t('claimWithExtension')) : null, - hasExt ? h('div', { class: 'small faint', style: 'text-align:center' }, t('orEnterCode')) : h('p', { class: 'small muted', style: 'text-align:center;margin:0' }, t('giftCodeHint')), - h('input', { type: 'text', class: 'mono-input', style: 'width:100%', placeholder: t('claimCodePlaceholder'), - autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: ui.claimCodeInput, - onInput: (e) => { ui.claimCodeInput = e.target.value; } }), - h('button', { class: (hasExt ? '' : 'btn-primary ') + 'btn-block', onClick: submitLockedCode }, t('claimGift')), - h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.claimLocked = null; ui.claimCodeInput = ''; ui.claimError = ''; render(); } }, t('dismiss')) - ) - ); -} -async function claimViaExtension() { - const ext = typeof globalThis !== 'undefined' && globalThis.nostr; - if (!ext) return; - ui.busy = true; ui.claimError = ''; render(); - try { - const pk = await ext.getPublicKey(); - if (pk !== ui.claimLocked.to) { ui.claimError = t('extWrongAccount'); ui.busy = false; render(); return; } - if (!ext.nip44 || !ext.nip44.decrypt) { ui.claimError = t('extNoNip44'); ui.busy = false; render(); return; } - const payload = await ext.nip44.decrypt(ui.claimLocked.eph, ui.claimLocked.ctKey); - if (!payload || !previewGift(payload)) { ui.claimError = t('extDecryptFailed'); ui.busy = false; render(); return; } - ui.busy = false; ui.claimLocked = null; ui.claimCodeInput = ''; ui.claimError = ''; - claimGift(payload); // claim into an existing wallet or a fresh one - } catch { - ui.busy = false; ui.claimError = t('extDecryptFailed'); render(); - } -} -function submitLockedCode() { - const code = (ui.claimCodeInput || '').trim(); - if (!code) return; - let payload = null; - try { payload = decryptWithCode(code, ui.claimLocked.ct); } catch {} - if (!payload || !previewGift(payload)) { ui.claimError = t('claimCodeWrong'); render(); return; } - ui.claimLocked = null; ui.claimCodeInput = ''; ui.claimError = ''; - claimGift(payload); // claim into an existing wallet or a fresh one -} -// Has this gift already been claimed? Its funding coin being spent — even by an -// unconfirmed claim — means the gift is gone; show the "already claimed" screen -// rather than let the user attempt a doomed double-spend. -async function checkGiftClaimed(code) { - const ops = giftOutpoints(code); - if (!ops.length) { ui.claimChecking = false; render(); return; } - try { - const res = await wallet.api.outspend(ops[0].txid, ops[0].vout); - if (res && res.spent) ui.claimTaken = { txid: res.txid || null }; - // Unknown txids don't 404 on the status/outspend endpoints, so verify the - // funding tx is actually visible — if not, the claimer is likely on a - // different network/data source than the sender (or it hasn't propagated). - if (!ui.claimTaken) { - const tx = await wallet.api.getTx(ops[0].txid).catch(() => null); - ui.claimNotVisible = !tx; - } - } catch { - // Couldn't check (offline/transient) — leave it claimable; the broadcast in - // doClaim is the final guard (a double-spend is rejected by the network). - } - ui.claimChecking = false; - render(); -} -// Broadcast the presigned gift to this fresh wallet's first receive address. -async function doClaim() { - if (wallet.offline) { ui.claimError = t('scanOffline'); render(); return; } - if (ui.claimChecking || ui.claimTaken) return; // not verified / already taken - ui.busy = true; - ui.claimError = ''; - render(); - try { - const rate = (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; - const to = wallet.receive[0] ? wallet.receive[0].address : wallet.derive(0, 0).address; - const claim = buildClaimTx(ui.claimCode, to, rate, wallet.netCfg.net); - await wallet.broadcast(claim.hex); - ui.claimedAmount = claim.amount; - const wasProvisional = !!(activeAccount() && activeAccount().provisional); - commitAccount(); // claimed — keep a fresh gift wallet (no-op for an existing one) - if (wasProvisional) { - ui.claimStep = 'backup'; // fresh wallet — show the new seed to back up - } else { - ui.screen = 'wallet'; // existing wallet — straight into it, no new seed to back up - ui.claimStep = null; - ui.tab = 'receive'; - ui.giftJustClaimed = claim.amount; // celebration shows instantly, no scan wait - } - wallet.scan().then(() => { - // The claim credit advances the fresh receive index in the background; - // ack it so the index-based celebration doesn't fire a second time - // behind the instant one we're already showing. - if (ui.giftJustClaimed != null) { - ui.receiveSeenIndex = wallet.nextReceiveIndex; - wallet.setReceiveAck(wallet.nextReceiveIndex); - render(); - } - }).catch(() => {}); - } catch (e) { - // Broadcast failed — most likely someone claimed it in the race window. - // Re-check the funding coin and, if spent, show the "already claimed" screen. - const ops = giftOutpoints(ui.claimCode); - if (ops.length) { - try { - const res = await wallet.api.outspend(ops[0].txid, ops[0].vout); - if (res && res.spent) ui.claimTaken = { txid: res.txid || null }; - } catch {} - } - if (!ui.claimTaken) { - ui.claimError = /missing.?inputs|missingorspent|bad-txns-inputs/i.test(String(e && e.message)) - ? t('claimNotVisibleBody') // funding tx unknown here: wrong chain/source - : t('claimFailed'); - } - } - ui.busy = false; - render(); -} + + + + + + + + // Wallets a gift could be claimed into: full accounts open this session plus the // durable directory (every wallet's xpub, watch-only), deduped by xpub. Claiming @@ -2088,188 +1687,13 @@ function claimTargets() { return [...open, ...loadWatchAccounts().filter((w) => !seen.has(w.xpub))]; } -// Entry point for claiming a gift code. If the recipient already has wallet(s), -// offer to claim into one or make a new one; otherwise go straight to a fresh -// wallet (the original first-timer flow). -function claimGift(code) { - ui.claimLocked = null; - const targets = claimTargets(); - if (targets.length) { accounts = targets; ui.claimChoose = { code }; render(); } - else enterWallet(newMnemonic(), '', { gift: code }); -} -// Claim a gift into an existing wallet (no new seed). activateAccount loads it and -// opens the claim screen; existingClaim keeps it committed and, in doClaim, skips -// the seed-backup step. -function claimIntoAccount(acc, code) { - ui.claimChoose = null; - activateAccount(acc, { gift: code, existingClaim: true, fresh: true }); -} -// Pick where to receive a gift: an existing wallet, or a brand-new one. -function claimChooseView() { - const code = ui.claimChoose.code; - const pv = previewGift(code); - return h( - 'div', - { class: 'col', style: 'gap:16px' }, - brandHeader(false), - h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px' }, - h('div', { class: 'check-badge', style: 'background:var(--accent)' }, '🎁'), - h('h2', { style: 'margin:0' }, t('giftWelcome')), - pv ? h('div', { class: 'amt', style: 'font-size:30px' }, h('span', { class: 'amount-pos' }, fmtAmount(pv.room)), ' ', unitTag('unit')) : null, - h('p', { class: 'muted', style: 'margin:0' }, t('claimIntoPrompt')) - ), - h('div', { class: 'card col gap6' }, - h('div', { class: 'small muted' }, t('claimIntoExisting')), - ...accounts.map((a) => - h('button', { class: 'btn-block', style: 'display:flex;justify-content:space-between;align-items:center;text-align:left', onClick: () => claimIntoAccount(a, code) }, - h('span', {}, - a.label, - a.type === 'watch' ? h('span', { class: 'small faint', style: 'margin-left:6px' }, '(' + t('watchOnly') + ')') : null), - h('span', { class: 'muted', style: 'font-size:18px;line-height:1' }, '\u203a')) - ), - h('div', { class: 'small faint', style: 'text-align:center' }, t('claimOr')), - h('button', { class: 'btn-primary btn-block', onClick: () => { ui.claimChoose = null; enterWallet(newMnemonic(), '', { gift: code }); } }, t('claimNewWallet')) - ) - ); -} -// Gift-claim flow: a fresh wallet only the claimer controls. Step 'welcome' -// shows the amount + a Claim button; 'backup' (after a successful claim) shows -// the fresh recovery phrase to write down. -function claimScreen() { - if (ui.claimStep === 'backup') { - const words = wallet.mnemonic.split(' '); - const claimed = ui.claimedAmount > 0; // false when "create a wallet anyway" - return h( - 'div', - { class: 'col', style: 'gap:16px' }, - brandHeader(false), - h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:8px' }, - h('div', { style: 'font-size:72px;line-height:1' + (claimed ? ';color:var(--green)' : '') }, claimed ? '✓' : '🔑'), - h('h2', { style: 'margin:0' }, claimed ? t('claimedTitle') : t('newWalletTitle')), - h('p', { class: 'muted', style: 'margin:0' }, claimed ? t('claimedBody') : t('newWalletBody')) - ), - h('div', { class: 'card col' }, - h('h3', {}, t('recoveryPhrase')), - h('div', { class: 'warn-box' }, t('writeDownWarn')), - h('div', { class: 'words' }, - words.map((w, i) => h('div', { class: 'w' }, h('span', { class: 'n' }, i + 1), h('span', { class: 't' }, w))) - ), - h('div', { class: 'row gap6' }, copyBtn(wallet.mnemonic, t('copyPhrase'))), - h('button', { class: 'btn-primary btn-block', onClick: () => { ui.confirm = pickConfirm(words); ui.claimError = ''; ui.claimStep = 'verify'; render(); } }, t('verifyBackup')), - h('button', { class: 'btn-block', onClick: () => { ui.screen = 'wallet'; ui.claimStep = null; render(); } }, t('skipVerification')) - ) - ); - } - if (ui.claimStep === 'verify') { - // Same word-confirmation as new-wallet creation: prove the recipient wrote - // the phrase down before sending them off into their freshly funded wallet. - const words = wallet.mnemonic.split(' '); - return h( - 'div', - { class: 'col', style: 'gap:16px' }, - brandHeader(false), - h('div', { class: 'card col' }, - h('h3', { style: 'margin-top:0' }, t('recoveryPhrase')), - h('p', { class: 'muted', style: 'margin:0' }, t('confirmBackupIntro')), - ...ui.confirm.map((c, i) => - h('label', { class: 'field' }, - h('span', { class: 'lab' }, t('wordN', { n: c.index + 1 })), - h('input', { - type: 'text', class: 'mono-input', autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', - value: c.value, onInput: (e) => (ui.confirm[i].value = e.target.value.trim()), - }) - ) - ), - ui.claimError && h('div', { class: 'notice err' }, ui.claimError), - h('div', { class: 'row gap6' }, - h('button', { class: 'btn-ghost', onClick: () => { ui.claimStep = 'backup'; ui.claimError = ''; render(); } }, t('back')), - h('button', { - class: 'btn-primary grow', - onClick: () => { - const ok = ui.confirm.every((c) => c.value.toLowerCase() === words[c.index]); - if (!ok) { ui.claimError = t('wordsMismatch'); render(); return; } - ui.screen = 'wallet'; ui.claimStep = null; ui.claimError = ''; - render(); - }, - }, t('openWallet')) - ), - h('button', { class: 'btn-block', onClick: () => { ui.screen = 'wallet'; ui.claimStep = null; render(); } }, t('skipVerification')) - ) - ); - } - // While verifying the gift is still unclaimed, show a loading screen rather - // than flashing the claimable amount (which would then jump to "already - // claimed" for a spent gift). - if (ui.claimChecking) { - return h( - 'div', - { class: 'col', style: 'gap:16px' }, - brandHeader(false), - h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;padding:32px 14px' }, - h('span', { class: 'spinner' }), - h('p', { class: 'muted', style: 'margin:0' }, t('giftChecking')) - ) - ); - } - // Already claimed (funding coin spent, possibly only in the mempool) — show a - // dead end with a link to the claim, instead of a claimable amount. - if (ui.claimTaken) { - return h( - 'div', - { class: 'col', style: 'gap:16px' }, - brandHeader(false), - h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px' }, - h('div', { style: 'font-size:56px;line-height:1' }, '🎁'), - h('h2', { style: 'margin:0' }, t('giftTakenTitle')), - h('p', { class: 'muted', style: 'margin:0' }, t('giftTakenBody')) - ), - // A fresh wallet was already generated for the claim — offer to keep it, - // view the claim on a block explorer, or head back to the home screen. - h('button', { class: 'btn-primary btn-block', onClick: () => { - ui.claimTaken = null; ui.claimedAmount = 0; - if (activeAccount() && activeAccount().provisional) { ui.claimStep = 'backup'; commitAccount(); } - else { ui.screen = 'wallet'; ui.claimStep = null; } - render(); - } }, activeAccount() && activeAccount().provisional ? t('createWalletAnyway') : t('goToWallet')), - ui.claimTaken.txid - ? h('a', { class: 'btn btn-block', href: wallet.api.explorerTx(ui.claimTaken.txid), target: '_blank', rel: 'noopener', onClick: (e) => { e.preventDefault(); openExternal(wallet.api.explorerTx(ui.claimTaken.txid)); } }, t('viewOnMempool')) - : null, - h('button', { class: 'btn-ghost btn-block', onClick: goHome }, t('goToHome')) - ); - } - const pv = previewGift(ui.claimCode); - // The headline is the full amount received (inputs minus the sender's change). - // The network fee is determined now, at claim time, and comes out of that - // amount — we surface the estimate on the Claim button so it isn't a surprise. - const rate = Math.max(1, Math.round((wallet.feeRates && wallet.feeRates.halfHourFee) || 5)); - const estFee = pv ? Math.ceil((11 + 68 * pv.inputs + 31 * 2) * rate) : 0; - const total = pv ? pv.room : 0; - return h( - 'div', - { class: 'col', style: 'gap:16px' }, - brandHeader(false), - h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px' }, - h('div', { class: 'check-badge', style: 'background:var(--accent)' }, '🎁'), - h('h2', { style: 'margin:0' }, t('giftWelcome')), - h('div', { class: 'amt', style: 'font-size:30px' }, - h('span', { class: 'amount-pos' }, fmtAmount(total)), ' ', unitTag('unit') - ), - h('p', { class: 'muted', style: 'margin:0' }, - (() => { const acc = activeAccount(); // claiming into an existing wallet vs a brand-new one - return acc && !acc.provisional ? t('claimBodyExisting', { name: acc.label }) : t('claimBody'); })()) - ), - ui.claimError && h('div', { class: 'notice err' }, ui.claimError), - !ui.claimError && ui.claimNotVisible && h('div', { class: 'notice info' }, t('claimNotVisibleBody')), - ui.busy || ui.claimChecking - ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' })) - : h('button', { class: 'btn-primary btn-block', onClick: doClaim }, - t('claimBtn'), ' ', - h('span', { style: 'font-size:0.85em;opacity:0.9' }, '(' + t('claimFeeNote', { n: fmtAmount(estFee) + ' ' + unitLabel() }) + ')')) - ); -} + + + + // Account switcher: pick a wallet, add another, or lock the session. function accountsScreen() { @@ -2524,7 +1948,6 @@ function offlineBanner() { function balanceCard() { // Only dim on the very first load; background updates happen silently. const firstLoad = wallet.scanning && !wallet.loaded; - const locked = wallet.lockedValue; const pending = wallet.pendingIncoming; const featLines = featureAll('balanceLines'); return h( @@ -2535,16 +1958,13 @@ function balanceCard() { // change, minus gift locks — so a pending spend debits immediately, while a // pending incoming receive stays out of it until it confirms. h('div', { class: 'amt', style: firstLoad ? 'opacity:.3' : '' }, fmtAmount(wallet.spendable), ' ', unitTag('unit')), - pending > 0 || locked > 0 || featLines.length + pending > 0 || featLines.length ? h( 'div', { class: 'split' }, pending > 0 ? h('div', {}, h('div', { class: 'k' }, t('pending')), h('div', { class: 'v pending' }, fmtAmount(pending), ' ', unitTag())) : null, - locked > 0 - ? h('div', {}, h('div', { class: 'k' }, t('lockedInGifts')), h('div', { class: 'v' }, fmtAmount(locked), ' ', unitTag())) - : null, ...featLines.map((l) => h('div', {}, h('div', { class: 'k' }, l.label), h('div', { class: 'v' }, fmtAmount(l.sat), ' ', unitTag()))) ) @@ -2600,29 +2020,6 @@ function hasRecentIncoming() { // ---------------------------------------------------------------- Receive function receiveTab() { - // A gift was just claimed into this wallet — celebrate immediately (the - // background scan credits the coin and advances the address meanwhile). - if (ui.giftJustClaimed != null) { - const amt = ui.giftJustClaimed; - return h( - 'div', - { - class: 'card col', - style: 'align-items:center;text-align:center;gap:14px;cursor:pointer;padding:48px 20px', - onClick: () => { - ui.giftJustClaimed = null; - ui.receiveSeenIndex = wallet.nextReceiveIndex; - wallet.setReceiveAck(wallet.nextReceiveIndex); - render(); - }, - }, - h('div', { class: 'check-badge' }, '✓'), - h('h2', { style: 'margin:0' }, t('paymentReceived')), - amt ? h('div', { class: 'amount-pos', style: 'font-size:18px' }, '+' + fmtAmount(amt) + ' ' + unitLabel()) : null, - h('div', { class: 'small muted' }, t('tapToProceed')) - ); - } - // Feature takeovers: boarding success, Ark receive celebration, etc. const takeover = featureHook('receiveTakeover'); if (takeover) return takeover; @@ -2735,20 +2132,10 @@ function sendTab() { const featView = featureHook('sendView'); if (featView) return featView; if (ui.draft) return reviewView(); - if (ui.giftMode) return giftView(); return sendForm(); } -// The gift UI as a send-page sub-view (entered from a link on the send form). -function giftView() { - return h( - 'div', - { class: 'col', style: 'gap:12px' }, - giftCard(), - // The split-offer card has its own Back; don't show a second one under it. - ui.giftSplitOffer ? null : h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.giftCode = null; ui.giftError = ''; ui.giftMax = false; ui.giftSplitOffer = null; ui.revokeId = null; goBack(() => { ui.giftMode = false; }); } }, t('back')) - ); -} + // QR scanning is only possible in a secure context with a camera. const canScan = () => @@ -3127,7 +2514,7 @@ function sendForm() { ready && !arkDest ? coinControl() : null, ui.sendError && h('div', { class: 'notice err' }, ui.sendError), ready && h('button', { class: 'btn-primary btn-block', onClick: reviewSend }, t('reviewTx')), - h('button', { class: 'linklike small', style: 'align-self:center;margin-top:2px', onClick: () => { ui.giftMode = true; ui.sendError = ''; render(); } }, '🎁 ' + t('giftLink')) + ...FEATURES.map((f) => (f.sendFormExtras ? f.sendFormExtras() : null)) ); } @@ -3373,76 +2760,23 @@ function sendResultView() { ); } -// ---------------------------------------------------------------- History -// An outstanding sent gift shown in History: tappable to cancel (reclaim the -// coin for a future payment, or revoke the link on-chain) without going through -// the Send → gift card. Reuses the same confirm state and handlers. -function giftHistoryItem(g) { - const amt = g.value != null ? g.value : g.amount; - const created = (wallet.loaded && (wallet.giftRecords()[g.id] || {}).created) || g.created; - return h('div', { class: 'item', style: 'cursor:pointer', onClick: () => { ui.giftDetail = g.id; render(); } }, - h('div', { class: 'ico out' }, '🎁'), - h('div', { class: 'grow' }, - h('div', { class: 'row gap6' }, t('giftHistoryTitle'), - h('span', { class: 'tag ' + (g.claimed ? 'conf' : 'pending') }, g.claimed ? t('giftClaimedTag') : g.reserved ? t('giftUnclaimedTag') : t('giftReclaimedTag'))), - h('div', { class: 'small faint' }, created ? timeAgo(created / 1000) : (!g.claimed && g.reserved ? t('lockedInGifts') : '')) - ), - h('div', { style: 'text-align:right' }, - amt != null ? h('div', { class: 'amount' }, fmtAmount(amt)) : null) - ); -} -// Gift detail page: status + the link itself (unclaimed), cancel/reclaim. -function giftDetailView(g) { - const amt = g.value != null ? g.value : g.amount; - const created = (wallet.loaded && (wallet.giftRecords()[g.id] || {}).created) || g.created; - const rec = g.claimed ? null : wallet.giftLink(g.id); - const line = (k, v) => h('div', { class: 'line' }, h('span', { class: 'k' }, k), h('span', { class: 'v' }, v)); - const back = () => { ui.giftDetail = null; ui.revokeId = null; render(); }; - return h( - 'div', - { class: 'card col' }, - h('div', { class: 'row between' }, - h('h3', {}, '🎁 ' + t('giftHistoryTitle')), - h('span', { class: 'tag ' + (g.claimed ? 'conf' : 'pending') }, g.claimed ? t('giftClaimedTag') : g.reserved ? t('giftUnclaimedTag') : t('giftReclaimedTag'))), - amt != null - ? h('div', { class: 'amt', style: 'font-size:30px' }, h('span', { class: 'amount' }, fmtAmount(amt)), ' ', unitTag('unit')) - : null, - h('div', { class: 'summary col', style: 'gap:0' }, - created ? line(t('dateLabel'), new Date(created).toLocaleString()) : null, - !g.claimed && g.reserved ? line(t('status'), t('lockedInGifts')) : null), - ui.revokeId === g.id - ? h('div', { class: 'col', style: 'gap:8px' }, - h('span', { class: 'small muted' }, g.reserved ? t('giftReclaimPrompt') : t('giftRevokeConfirm')), - ui.busy - ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' })) - : h('div', { class: 'row gap6' }, - g.reserved ? h('button', { class: 'btn-ghost grow', onClick: () => doReclaim(g.id) }, t('giftReclaim')) : null, - h('button', { class: 'btn-primary grow', onClick: () => doRevoke(g.id) }, t('giftRevoke'))), - ui.busy ? null : h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.revokeId = null; render(); } }, t('back'))) - : g.claimed - ? null - : h('div', { class: 'row gap6' }, - rec ? h('button', { class: 'grow', onClick: () => { ui.viewGift = rec; render(); } }, t('giftView')) : null, - h('button', { class: 'grow', onClick: () => { ui.revokeId = g.id; render(); } }, t('giftCancel'))), - ui.revokeId === g.id ? null : h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back')) - ); -} + + function txHistoryItem(tx) { const incoming = tx.net >= 0; const stuck = !tx.confirmed && wallet.isStuck(tx); - const gift = !incoming && wallet.loaded - ? wallet.claimedGifts().find((c) => c.claimTxid === tx.txid) : null; + const deco = featureHook('decorateTxRow', tx); // e.g. a claimed gift's claim tx return h( 'div', { class: 'item', style: 'cursor:pointer', onClick: () => openTx(tx.txid) }, - h('div', { class: `ico ${gift ? 'out' : incoming ? 'in' : 'out'}` }, gift ? '🎁' : incoming ? '↓' : '↑'), + h('div', { class: `ico ${incoming ? 'in' : 'out'}` }, deco ? deco.icon : incoming ? '↓' : '↑'), h('div', { class: 'grow' }, h('div', { class: 'row gap6' }, - gift ? h('span', {}, t('giftHistoryTitle'), ' ', h('span', { class: 'tag conf' }, t('giftClaimedTag'))) : + deco ? deco.label : incoming ? t('received') : t('sent'), tx.confirmed ? null : stuck ? h('span', { class: 'tag', style: 'background:var(--red-soft);color:var(--red)' }, t('stuckTag')) @@ -3474,14 +2808,6 @@ function historyTab() { const txs = wallet.history; // BIP84 txs + silent-payment receipts, newest first const featDetail = featureHook('historyDetail'); if (featDetail) return featDetail; - if (ui.giftDetail) { - const g = wallet.loaded - ? [...wallet.outstandingGifts(), ...wallet.claimedGifts().map((c) => ({ ...c, claimed: true }))] - .find((x) => x.id === ui.giftDetail) - : null; - if (g) return giftDetailView(g); - ui.giftDetail = null; - } if (ui.txDetail) { const tx = txs.find((x) => x.txid === ui.txDetail); if (tx) return txDetailView(tx); @@ -3496,39 +2822,16 @@ function historyTab() { h('span', { class: 'spinner' }), wallet.historyLoading ? h('p', { class: 'small muted', style: 'margin:0' }, t('loadingHistory')) : null ); - // 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 - // tx spent it (resolved lazily below) — no duplicate gift + Sent entries. - const txids = new Set(txs.map((x) => x.txid)); - const claimed = wallet.loaded ? wallet.claimedGifts() : []; - for (const c of claimed) { - if (c.claimTxid || !c.outpoints || !c.outpoints.length) continue; - if (!ui._giftClaimResolving) ui._giftClaimResolving = new Set(); - if (ui._giftClaimResolving.has(c.id) || wallet.offline) continue; - ui._giftClaimResolving.add(c.id); - const [txid, vout] = c.outpoints[0].split(':'); - wallet.api.outspend(txid, Number(vout)).then((res) => { - if (res && res.spent && res.txid) { wallet.setGiftClaimTx(c.id, res.txid); render(); } - }).catch(() => {}).finally(() => ui._giftClaimResolving.delete(c.id)); - } - const gifts = wallet.loaded - ? [...wallet.outstandingGifts(), ...claimed.filter((c) => !(c.claimTxid && txids.has(c.claimTxid))).map((c) => ({ ...c, claimed: true }))] - : []; // Feature movements (Ark receives/sends/boards, ...) interleave with // on-chain txs by time. const featEntries = featureAll('historyEntries', txs); - if (!txs.length && !gifts.length && !featEntries.length) + if (!txs.length && !featEntries.length) return h('div', { class: 'card' }, h('p', { class: 'muted center', style: 'margin:0' }, t('noTxYet'))); - // Gifts, transactions, and Ark movements merge into one timeline (claimed - // gifts whose claim tx is in the list ride that tx's row instead). - const recs = wallet.loaded ? wallet.giftRecords() : {}; - const giftTime = (g) => (recs[g.id] && recs[g.id].created) || g.created || Date.now(); + // Transactions and feature entries merge into one timeline. const entries = [ ...txs.map((tx) => ({ time: tx.confirmed ? (tx.blockTime || 0) * 1000 : Date.now(), render: () => txHistoryItem(tx) })), ...featEntries, - ...gifts.map((g) => ({ time: giftTime(g), render: () => giftHistoryItem(g) })), ].sort((a, b) => b.time - a.time); const txPages = Math.ceil(entries.length / PAGE_SIZE); const txPage = Math.min(ui.txPage, Math.max(0, txPages - 1)); @@ -3578,7 +2881,7 @@ async function openTx(txid) { function txDetailView(tx) { const incoming = tx.net >= 0; - const gift = wallet.loaded ? wallet.claimedGifts().find((c) => c.claimTxid === tx.txid) : null; + const line = (k, v) => h('div', { class: 'line' }, h('span', { class: 'k' }, k), h('span', { class: 'v' }, v)); return h( 'div', @@ -3597,12 +2900,7 @@ function txDetailView(tx) { tx.confirmed && tx.blockTime ? line(t('date'), new Date(tx.blockTime * 1000).toLocaleString()) : null, tx.fee ? line(t('networkFee'), fmtAmount(tx.fee) + ' ' + unitLabel()) : null ), - featureHook('txDetailSection', tx), - gift - ? h('div', { class: 'summary col', style: 'gap:0' }, - h('div', { style: 'font-weight:600;margin:12px 0 2px' }, '🎁 ' + t('giftClaimedContext')), - gift.amount != null ? h('div', { class: 'line' }, h('span', { class: 'k' }, t('giftAmountLabel')), h('span', { class: 'v' }, fmtAmount(gift.amount) + ' ' + unitLabel())) : null) - : null, + ...FEATURES.map((f) => (f.txDetailSection ? f.txDetailSection(tx) : null)), !tx.confirmed && wallet.isStuck(tx) ? h('div', { class: 'warn-box' }, incoming ? t('stuckIncomingNote') : t('stuckOutgoingNote')) : null, @@ -3673,9 +2971,12 @@ async function importSnapshotFile(e) { // only invoked at runtime (first render happens after loadLocale below). const ctx = { h, ui, render, wallet, toast, copyBtn, pasteBtn, blankSend, goBack, openExternal, - fmtAmount, unitLabel, unitTag, parseAmount, getUnit: () => unit, + fmtAmount, unitLabel, unitTag, parseAmount, getUnit: () => unit, toggleUnit, download, + brandHeader, profileChip, activeAccount, setAccounts: (list) => { accounts = list; }, + getAccounts: () => accounts, + claimTargets, enterWallet, activateAccount, commitAccount, }; -const FEATURES = [swapsFeature, arkFeature, spFeature].map((f) => f(ctx)); +const FEATURES = [giftsFeature, swapsFeature, arkFeature, spFeature].map((f) => f(ctx)); // apply text direction, then restore a wallet left open in this tab — otherwise // show the unlock screen. @@ -3690,9 +2991,7 @@ document.addEventListener('visibilitychange', () => { }); loadLocale(getLang()).finally(() => { applyBootAutoLogout(); // clear an overdue session before we read it for claim targets - const code = readGiftHash(); - if (code) { claimGift(code); return; } // bearer gift → claim into an existing wallet or a new one - ui.claimLocked = readLockedGiftHash(); // locked gift → claim with your own wallet + if (featureHook('bootUrl')) return; // a feature consumed the URL (e.g. a gift claim) if (!restoreAccountsState()) render(); }); |
