diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 4 | ||||
| -rw-r--r-- | src/features/ark.js | 46 | ||||
| -rw-r--r-- | src/features/messages.js | 43 | ||||
| -rw-r--r-- | src/features/zaps.js | 8 | ||||
| -rw-r--r-- | src/i18n.js | 4 | ||||
| -rw-r--r-- | src/style.css | 13 |
6 files changed, 85 insertions, 33 deletions
@@ -5723,8 +5723,8 @@ const ctx = { // as money landing: without this the balance counts up from 0 in green, // which is the celebration a received payment gets. forgetAmountAnim: () => _amtLast.clear(), - // the one-tap zap amount, synced across devices with the rest of the state - zapDefaultSat: () => (wallet.loadFeatureState ? (wallet.loadFeatureState('prefs', {}).zapSat || 0) : 0), + // Start at 21 sats; an explicitly saved preference wins across devices. + zapDefaultSat: () => (wallet.loadFeatureState ? (wallet.loadFeatureState('prefs', {}).zapSat ?? 21) : 21), setZapDefaultSat: (n) => { const p = wallet.loadFeatureState('prefs', {}); p.zapSat = Math.max(1, Math.floor(n) || 0); diff --git a/src/features/ark.js b/src/features/ark.js index 8fe21cc..3577242 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -733,6 +733,12 @@ export function arkFeature(ctx) { return { spendableSat: sum('spendable'), pendingSat: sum('pending'), boardingSat }; } + function renewingSat() { + const s = arkStateNow(); + const ids = new Set((s?.actions || []).filter((a) => a.type === 'refresh' && !['done', 'failed'].includes(a.step)).flatMap((a) => a.inputIds || [])); + return (s?.vtxos || []).filter((v) => v.state === 'pending' && ids.has(v.id)).reduce((n, v) => n + v.amountSat, 0); + } + // An ark address in the send form signals a send is coming: verify our // spendable vtxos against the server now, so a stale one (same seed active // elsewhere, restored state) is dropped before coin selection instead of @@ -1954,7 +1960,7 @@ export function arkFeature(ctx) { ? h('button', { class: 'btn-block', disabled: !!ui.busy, onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub, z.eventId); } }, '⚡ ' + t('lnZapFallback')) : null, payable && spendable < 330 - ? h('div', { class: 'notice info' }, t('zapNoBalance')) + ? h('div', { class: 'notice info' }, t(spendable + renewingSat() >= 330 ? 'spendingRenewingPayment' : 'zapNoBalance')) : payable ? amountInputs(t('arkZapHint')) : null, @@ -2203,6 +2209,7 @@ export function arkFeature(ctx) { if (!mgr) connectArk().then(() => render()).catch(() => {}); const totalSat = spend.reduce((n, v) => n + v.amountSat, 0); const heldSat = totalSat + pendingCoins.reduce((n, v) => n + v.amountSat, 0); + const spendableSat = arkBalance()?.spendableSat || 0; let exitFee = 0; try { exitFee = estimateExitFeeSat(mgr); } catch {} const feeRate = Math.max(1, (wallet.feeRates && wallet.feeRates.halfHourFee) || 2); const afterFee = Math.ceil(530 * feeRate); @@ -2390,6 +2397,17 @@ export function arkFeature(ctx) { ? t('arkCoinsRenewSome', { n: selCoins.length, fee: selFee > 0 ? fmtSats(selFee) + ' sats' : t('arkDepthFree') }) : selFee > 0 ? t('arkCoinsRenewNowFee', { fee: fmtSats(selFee) + ' sats' }) : t('arkDepthRenewBtn'))), h('div', { class: 'card col', style: 'gap:8px' }, + h('h4', { style: 'margin:0' }, t('arkCoinsCoopTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoopDesc')), + h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy || !spendableSat || wallet.watchOnly, onClick: () => { + const address = wallet.freshReceive().address; + ui.arkCoinsPage = null; ui.arkCoinsSel = null; ui.arkExitPage = null; ui.arkExitConfirm = null; + ui.arkOffboarded = null; ui.arkError = ''; + ctx.showSend({ fresh: true }); + ui.arkOffboardSend = { address, amount: String(spendableSat), fromCoins: true }; + render(); + } }, t('arkOffboardBtn', { n: fmtAmount(spendableSat) + ' ' + unitLabel() }))), + h('div', { class: 'card col', style: 'gap:8px' }, h('h4', { style: 'margin:0' }, t('arkCoinsExitTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoinsExitDesc', { fee: fmtSats(exitFee), after: fmtSats(afterFee) })), @@ -2889,8 +2907,11 @@ export function arkFeature(ctx) { const o = ui.arkOffboardSend; const spendable = arkBalance()?.spendableSat || 0; return h('div', { class: 'card col', style: 'gap:12px' }, - h('h3', {}, t('arkExitSendTitle')), - h('div', { class: 'small muted break' }, o.address), + h('h3', {}, t(o.fromCoins ? 'arkCoinsCoopTitle' : 'arkExitSendTitle')), + h('label', { class: 'field' }, + h('span', { class: 'lab' }, t('recipient')), + h('input', { type: 'text', class: 'mono-input', value: o.address, spellcheck: 'false', autocomplete: 'off', autocapitalize: 'none', + onInput: (e) => { o.address = e.target.value.trim(); } })), h('div', { class: 'small faint' }, t('arkExitSendNote')), h('div', { class: 'input-group' }, h('input', { type: 'number', min: '1', placeholder: String(spendable), value: o.amount, @@ -2898,14 +2919,19 @@ export function arkFeature(ctx) { h('button', { type: 'button', onClick: () => { o.amount = String(spendable); render(); } }, t('max'))), ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null, h('div', { class: 'row gap6' }, - h('button', { class: 'btn-ghost grow', onClick: () => { ui.arkOffboardSend = null; ui.arkError = ''; ui.send = blankSend(); render(); } }, t('back')), + h('button', { class: 'btn-ghost grow', onClick: () => { + ui.arkOffboardSend = null; ui.arkError = ''; ui.send = blankSend(); + if (o.fromCoins) ctx.goBack(() => { ui.arkCoinsPage = true; }); else render(); + } }, t('back')), ui.arkBusy === 'offboard' ? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner sm' })) : h('button', { class: 'btn-primary grow', onClick: () => { - const sats = parseInt(o.amount, 10) || spendable; - if (!sats || sats <= 0 || sats > spendable) { ui.arkError = t('enterValidAmtForN', { n: 1 }); render(); return; } + const amount = String(o.amount ?? '').trim(); + const sats = amount ? Number(amount) : spendable; + if (!Number.isSafeInteger(sats) || sats <= 0 || sats > spendable) { ui.arkError = t('enterValidAmtForN', { n: 1 }); render(); return; } + try { addrScript(o.address); } catch { ui.arkError = t('arkOffboardBadAddress'); render(); return; } doArkOffboard(sats >= spendable ? 0 : sats, o.address); - } }, t('send'))), + } }, t('arkSendBtn'))), // This send IS the cooperative exit. The trustless fallback only // appears when cooperation actually failed — and it states, before // anything is confirmed, exactly what it does and what the mining @@ -2942,6 +2968,8 @@ export function arkFeature(ctx) { async function doArkOffboard(amountSat, destAddress = null) { ui.arkBusy = 'offboard'; ui.arkError = ''; render(); try { + const address = destAddress || wallet.freshReceive().address; + const spk = addrScript(address); const mgr = await connectArk(); const spendables = () => mgr.state.vtxos.filter((v) => v.state === 'spendable'); const total = spendables().reduce((n, v) => n + v.amountSat, 0); @@ -2960,11 +2988,10 @@ export function arkFeature(ctx) { } ids = [target.id]; } - const address = destAddress || wallet.freshReceive().address; - const spk = btc.OutScript.encode(btc.Address(wallet.netCfg.net).decode(address)); const action = await mgr.startOffboard(spk, address, ids); ui.arkOffboardAmt = ''; ui.arkMoveOpen = false; + if (ui.arkOffboardSend?.fromCoins) ui.tab = 'receive'; ui.arkOffboardSend = null; ui.send = blankSend(); ui.arkOffboarded = { txid: action.txid, netSat: action.netSat, feeSat: action.feeSat }; @@ -3515,6 +3542,7 @@ export function arkFeature(ctx) { return mgr.fetchBolt12(offerStr, amountSat); }, lnSpendableSat() { const b = arkBalance(); return b ? b.spendableSat : 0; }, + spendingRenewingSat() { return renewingSat(); }, // Max for any Lightning amount form: spendable minus estimated fees. lnMaxSendSat() { return lnMaxSat(); }, settingsCards() { return [autoWithdrawCard()]; }, diff --git a/src/features/messages.js b/src/features/messages.js index 54cbfa3..38fd1f6 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -7395,7 +7395,7 @@ export function messagesFeature(ctx) { } }, }), - h('div', { class: 'row reply-tools', style: 'gap:8px;align-items:center' }, + h('div', { class: 'row reply-tools wrap', style: 'gap:8px;align-items:center' }, // A reply can carry a picture too — same upload, same imeta tag, same // paperclip. The URL lands in the draft, which is what every client // reads as the media. @@ -7416,12 +7416,13 @@ export function messagesFeature(ctx) { }); }, }), - previewBtn(!!s.preview, () => { s.preview = !s.preview; render(); }), h('span', { class: 'grow' }), - h('button', { - class: 'btn-ghost btn-sm thread-reply-cancel', type: 'button', - onClick: () => { s.replying = false; s.preview = false; render(); }, - }, t('cancel')), + h('div', { class: 'row gap6 composer-options' }, + previewBtn(!!s.preview, () => { s.preview = !s.preview; render(); }), + h('button', { + class: 'btn-ghost btn-sm thread-reply-cancel', type: 'button', + onClick: () => { s.replying = false; s.preview = false; render(); }, + }, t('cancel'))), h('button', { class: 'btn-primary btn-sm thread-reply-send', disabled: !!s.sending || !!ui.postUploading, onClick: async () => { @@ -7797,13 +7798,18 @@ export function messagesFeature(ctx) { ? h('p', { class: 'small', style: 'margin:0;white-space:pre-wrap;overflow-wrap:anywhere' }, ...noteBody(about.slice(0, 1000))) : null, - // the npub, tap to copy — on every profile including your own; it - // only steps aside while the (long) edit form is open + // A readable public-ID preview; copying always uses the full npub. + // It only steps aside while the (long) edit form is open. ui.profEdit ? null : h('button', { - class: 'addr-box break npub-box', title: t('copy'), - style: 'font-size:11px;cursor:pointer;text-align:left;width:100%', + type: 'button', class: 'profile-key', title: npub, + 'aria-label': t('copy') + ': ' + t('profPublicKey'), onClick: async () => { try { await navigator.clipboard.writeText(npub); toast(t('copied')); } catch {} }, - }, npub), + }, + h('span', { class: 'profile-key-content' }, + h('span', { class: 'profile-key-label' }, t('profPublicKey')), + h('bdi', { class: 'profile-key-value', dir: 'ltr' }, npub.slice(0, 12) + '…' + npub.slice(-8))), + h('span', { class: 'profile-key-copy', 'aria-hidden': 'true', html: + '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="8" width="12" height="12" rx="3"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg>' })), ui.profEdit ? h('div', { class: 'col', style: 'gap:8px' }, // the username IS the payment address and NIP-05 — the frozen @@ -8059,7 +8065,6 @@ export function messagesFeature(ctx) { h('button', { class: 'btn-danger grow', onClick: cancelPost }, t('postDiscard'))))); } const CLIP = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>'; - const EYE = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>'; // ---- draft preview -------------------------------------------------------- // A post is written as plain text, but it doesn't ARRIVE as plain text: a @@ -8087,11 +8092,12 @@ export function messagesFeature(ctx) { h('div', { class: 'media-progress-track' }, h('div', { class: 'media-progress-fill', style: 'width:' + m.pct + '%' }))); } const previewBtn = (on, toggle) => h('button', { - class: 'attach-btn' + (on ? ' on' : ''), + type: 'button', class: 'btn-ghost btn-sm compose-preview-toggle', title: on ? t('composePreviewHide') : t('composePreview'), - 'aria-label': on ? t('composePreviewHide') : t('composePreview'), + 'aria-label': t('composePreview'), + 'aria-pressed': String(on), onClick: toggle, - }, h('span', { style: 'display:flex', html: EYE })); + }, t('composePreview')); function postComposer() { if (ui.profCompose == null && !draftFor(POST_DRAFT)) return null; const text = composeText(); @@ -8113,7 +8119,7 @@ export function messagesFeature(ctx) { }), ui.postPreview ? draftPreview(text) : null, mediaBar(), - h('div', { class: 'row gap6' }, + h('div', { class: 'row gap6 wrap' }, h('button', { class: 'btn-primary grow', disabled: !!ui.postUploading, onClick: async () => { const body = composeText().trim(); if (!body) return; @@ -8136,12 +8142,13 @@ export function messagesFeature(ctx) { class: 'attach-btn', title: t('feedAttach'), 'aria-label': t('feedAttach'), disabled: !!ui.postUploading, onClick: () => document.getElementById('post-file')?.click(), }, ui.postUploading ? h('span', { class: 'spinner sm' }) : h('span', { style: 'display:flex', html: CLIP })) : null, - previewBtn(!!ui.postPreview, () => { ui.postPreview = !ui.postPreview; render(); }), h('input', { type: 'file', id: 'post-file', accept: 'image/*,video/*', style: 'display:none', onChange: async (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; await attachMedia(f); }, }), - h('button', { class: 'btn-ghost', onClick: requestCancelPost }, t('cancel'))), + h('div', { class: 'row gap6 composer-options' }, + previewBtn(!!ui.postPreview, () => { ui.postPreview = !ui.postPreview; render(); }), + h('button', { class: 'btn-ghost btn-sm', onClick: requestCancelPost }, t('cancel')))), postCancelWarning()); } diff --git a/src/features/zaps.js b/src/features/zaps.js index 7a5f335..a99de94 100644 --- a/src/features/zaps.js +++ b/src/features/zaps.js @@ -338,7 +338,7 @@ export function zapsFeature(ctx) { // Spending can't even cover their minimum, say so instead of rendering // an impossible range. const p = z.params; - const ours = hook('lnSpendableSat'); + const ours = hook('lnSpendableSat') ?? (hook('arkReady') ? 0 : null); const min = Math.ceil(p.minSendable / 1000); const max = Math.min(Math.floor(p.maxSendable / 1000), ours != null ? ours : Infinity); const broke = ours != null && (ours <= 0 || max < min); @@ -349,7 +349,9 @@ export function zapsFeature(ctx) { const typedSats = parseAmount(z.amount, getUnit()) || 0; const theirMaxSat = Math.floor(p.maxSendable / 1000); const boardable = !!hook('arkReady') && ours != null; - const needsBoard = boardable && (broke || (typedSats > 0 && typedSats > ours && typedSats <= theirMaxSat)); + const renewing = hook('spendingRenewingSat') || 0; + const waitingOnRenewal = renewing > 0 && broke && ours + renewing >= Math.max(min, p.fixedSat || typedSats); + const needsBoard = !waitingOnRenewal && boardable && (broke || (typedSats > 0 && typedSats > ours && typedSats <= theirMaxSat)); return h('div', { class: 'card col', style: 'gap:12px' }, h('h3', {}, heading), (z.target && z.target.pk && hook('profileChip', z.target.pk, 'lg')) || h('div', { class: 'small muted', style: 'word-break:break-all' }, z.name || z.address || ''), @@ -367,7 +369,7 @@ export function zapsFeature(ctx) { : null, h('div', { style: 'display:flex;align-items:center' }, unitTag())), broke - ? h('div', { class: 'notice info' }, t('zapNoBalance')) + ? h('div', { class: 'notice info' }, t(waitingOnRenewal ? 'spendingRenewingPayment' : 'zapNoBalance')) : p.fixedSat ? h('div', { class: 'small faint' }, t('clinkFixedAmount')) : h('div', { class: 'small faint' }, `Min ${min.toLocaleString()} · max ${max.toLocaleString()} sats`), diff --git a/src/i18n.js b/src/i18n.js index 8d67b3a..38d23c7 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -103,6 +103,7 @@ const STR = { profUsername: 'Username', profUploadPic: 'Upload photo', profDisplayName: 'Display name', + profPublicKey: 'Nostr public key', profFetchFailed: 'Couldn’t load the latest profile.', profNotFound: 'No profile details found on the available relays.', profUnameInvalid: 'Usernames can only use letters, numbers, dots, dashes and underscores.', @@ -445,6 +446,7 @@ const STR = { msgNpubPlaceholder: 'npub…', msgSearchPlaceholder: 'Name or npub…', zapNoBalance: "There's not enough in Spending to send a zap. Move some money into Spending first.", + spendingRenewingPayment: 'Your Spending coins are renewing. You can pay once the renewal finishes.', zapBoardBtn: 'Move sats into Spending', onbBoardTitle: 'Top up Spending', onbBoardBody: 'Move some sats from Savings into Spending. Payments there are instant, with no mining fees.', @@ -492,6 +494,8 @@ const STR = { arkRenewingHistory: 'Renewing', arkRenewingOpen: 'See the coins', arkCoinsExitTitle: 'Unilateral exit', + arkCoinsCoopTitle: 'Co-operative exit', + arkOffboardBadAddress: 'Enter a valid on-chain address for this network.', arkCoinsExitBtn: 'Exit now', arkExitConfirmTitle: 'Start a unilateral exit?', arkExitConfirmBody: 'This brings your Spending coins to Savings on-chain without the server, by publishing each coin’s transaction history. Nothing needs the server’s cooperation (that is the point), but it is slow and it costs mining fees.', diff --git a/src/style.css b/src/style.css index f77c7d9..c7aec59 100644 --- a/src/style.css +++ b/src/style.css @@ -1046,7 +1046,18 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } box-shadow: 0 0 0 2px var(--bg); } .chat-avatar.header-ava { width: 30px; height: 30px; font-size: 11px; } -.npub-box { display: flex; align-items: center; gap: 8px; } +.profile-key { + display: flex; align-items: center; gap: 16px; width: 100%; padding: 14px 16px; + border: 0; border-radius: 12px; background: var(--surface2); + font-family: var(--sans); text-align: start; +} +.profile-key:hover { background: var(--accent-soft); } +.profile-key-content { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; } +.profile-key-label { color: var(--muted); font-size: 12px; font-weight: 500; } +.profile-key-value { font-size: 14px; font-weight: 400; overflow-wrap: anywhere; } +.profile-key-copy { display: flex; flex-shrink: 0; color: var(--muted); } +.composer-options { margin-inline-start: auto; flex-shrink: 0; } +.compose-preview-toggle[aria-pressed="true"] { background: var(--accent-soft); color: var(--ink); } .copy-ico { flex-shrink: 0; background: none; border: none; padding: 2px; color: var(--muted); cursor: pointer; display: flex; } .copy-ico:hover { color: var(--ink); } .settings-nav { text-align: left; font-weight: 600; padding: 14px 16px; position: relative; } |
