From a42edcf6d605c1f5b8770b015ae17f0b4881bb42 Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Thu, 25 Jun 2026 20:48:38 -0400 Subject: [PATCH] Feed upgrade Phases 3-5: zap capability indicators, rail selector, balance checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 β€” Zap Capability Indicators: - Added resolveZapCapabilities() to zaps.mjs with 5-min TTL cache - Checks lud16 (from profile cache) for Lightning capability - Checks kind 10019 (via walletFetchMintList) for nutzap capability - Computes shared mints between sender and recipient - Added capability badges (πŸ₯œ/⚑) to zap button in post-interactions.mjs - Badges use var(--accent-color) for available, var(--muted-color) for unavailable - Added proactive kind 10019 batch fetch for followed users in ndk-worker.js - New IDB store ndk-nutzap-mintlists for persistent caching (6h TTL) - Non-blocking: badges appear after async resolution Phase 4 β€” Zap Rail Selector: - Updated promptZapDetails with rail toggle (πŸ₯œ Nutzap / ⚑ Lightning) - Shows Cashu balance and insufficient balance warning - Shows shared mint info when nutzap selected - Default rail: nutzap for <1000 sats, Lightning for >=1000 sats - Updated handleZapClick to resolve capabilities and respect user's rail choice - Added zap rail toggle CSS to client.css (all CSS variables) - Wired walletGetBalance through post-interactions2.mjs and post-feed.html Phase 5 β€” Balance Check and Error Handling: - Pre-flight balance check before sending (hard stop if insufficient) - Barely-sufficient balance confirmation dialog - Friendly error messages for melt/swap/timeout/insufficient failures - Post-zap balance refresh via ndkWalletBalance event Review fix: threaded walletGetMints through post-interactions2.mjs and post-feed.html so resolveZapCapabilities can compute shared mints correctly --- plans/feed-upgrade.md | 64 ++++--- www/css/client.css | 62 +++++++ www/js/post-interactions.mjs | 319 +++++++++++++++++++++++++++++++++- www/js/post-interactions2.mjs | 4 + www/js/version.json | 6 +- www/js/zaps.mjs | 307 +++++++++++++++++++++++++++++++- www/ndk-worker.js | 316 ++++++++++++++++++++++++++++++--- www/post-feed.html | 4 + 8 files changed, 1007 insertions(+), 75 deletions(-) diff --git a/plans/feed-upgrade.md b/plans/feed-upgrade.md index a4dde3f..255e95b 100644 --- a/plans/feed-upgrade.md +++ b/plans/feed-upgrade.md @@ -48,18 +48,18 @@ Remove inline replies from the feed. Show only the original post with interactio ### Tasks -- [ ] **1.1** In `feed2.html`, remove the inline comment rendering and the "Comments" toggle button. The feed should only show top-level posts. +- [x] **1.1** In `feed2.html`, remove the inline comment rendering and the "Comments" toggle button. The feed should only show top-level posts. -- [ ] **1.2** Change the comment button behavior β€” instead of calling `onCommentIntentFn` to show inline comments, open `post-feed.html?event=` in a **new tab** using `window.open('./post-feed.html?event=' + encodeURIComponent(postId), '_blank')`. +- [x] **1.2** Change the comment button behavior β€” instead of calling `onCommentIntentFn` to show inline comments, open `post-feed.html?event=` in a **new tab** using `window.open('./post-feed.html?event=' + encodeURIComponent(postId), '_blank')`. -- [ ] **1.3** Keep the interaction bar functional in the feed: +- [x] **1.3** Keep the interaction bar functional in the feed: - βœ… Like button β€” works inline (no navigation) - βœ… Zap button β€” works inline (opens zap dialog, sends nutzap or Lightning melt) - βœ… Nutzap count display β€” shows total nutzaps received - - πŸ”„ Comment button β€” opens `post-feed.html?event=` in a new tab - - πŸ”„ Quote button β€” opens quote composer (no navigation) + - βœ… Comment button β€” opens `post-feed.html?event=` in a new tab + - βœ… Quote button β€” opens quote composer (no navigation) -- [ ] **1.4** Keep real-time updates for counts (likes, zaps, comments, nutzaps) β€” the feed should still update these counts as events arrive, just not render the reply content inline. +- [x] **1.4** Keep real-time updates for counts (likes, zaps, comments, nutzaps) β€” the feed should still update these counts as events arrive, just not render the reply content inline. ### Files to modify @@ -86,27 +86,28 @@ Accepts the same event identifier formats as the rest of the project: ### Tasks -- [ ] **2.1** Create `www/post-feed.html` based on the [`www/template.html`](www/template.html) pattern β€” standard header, hamburger, sidenav, footer. +- [x] **2.1** Create `www/post-feed.html` based on the [`www/template.html`](www/template.html) pattern β€” standard header, hamburger, sidenav, footer. -- [ ] **2.2** Parse the `event` URL parameter using `URLSearchParams` (same pattern as `post.html` line 248). Decode nevent/note to hex if needed. +- [x] **2.2** Parse the `event` URL parameter using `URLSearchParams` (same pattern as `post.html` line 248). Decode nevent/note to hex if needed. -- [ ] **2.3** Fetch the original post event via the worker's `ndkFetchEvents` or `queryCache` function. Display it using `renderPostItem` from `post-interactions.mjs`. +- [x] **2.3** Fetch the original post event via the worker's `ndkFetchEvents` or `queryCache` function. Display it using `renderPostItem` from `post-interactions.mjs`. -- [ ] **2.4** Fetch all replies β€” kind 1 events with an `e` tag pointing to the post ID. Use `ndkFetchEvents` with a filter like `{ kinds: [1], '#e': [postId] }`. +- [x] **2.4** Fetch all replies β€” kind 1 events with an `e` tag pointing to the post ID. Use `ndkFetchEvents` with a filter like `{ kinds: [1], '#e': [postId] }`. **Updated:** Now uses recursive fetching (`fetchRepliesRecursive`) to find nested replies up to 5 levels deep. -- [ ] **2.5** Render the comment thread below the original post: - - Flat thread (all replies in chronological order) β€” simplest, most reliable - - Each reply rendered using `renderPostItem` from `post-interactions.mjs` - - Start with flat; can add nesting/threading later +- [x] **2.5** Render the comment thread below the original post: + - Threaded with vertical indent lines (Phase 2.5) + - Each reply rendered using `renderPostItem` from `post-interactions2.mjs` + - Depth-first ordering with level computation from NIP-10 e tags -- [ ] **2.6** Add an inline composer at the bottom for posting replies. The composer should: - - Tag the original post: `['e', postId, '', 'reply']` - - Tag the original author: `['p', postPubkey]` - - Use the existing post composer component (`post-composer.mjs`) +- [x] **2.6** Add an inline composer at the **top** (under the main post, before replies) for posting replies. The composer: + - Tags the original post: `['e', postId, '', 'reply']` + - Tags the original author: `['p', postPubkey]` + - Uses the existing post composer component (`post-composer.mjs`) + - Styled to match `post.html`'s composer (`.topPostComposerInput` CSS) -- [ ] **2.7** Real-time updates β€” subscribe to new replies via the worker's subscription system. Append new replies to the thread as they arrive. +- [x] **2.7** Real-time updates β€” subscribe to new replies via the worker's subscription system. Append new replies to the thread as they arrive. New replies trigger a 1-level recursive fetch for their sub-replies. -- [ ] **2.8** Each reply should have its own interaction bar (like, zap, quote) via `post-interactions.mjs`. The comment button on a reply opens `post-feed.html?event=` in a new tab for that reply's sub-thread. +- [x] **2.8** Each reply should have its own interaction bar (like, zap, quote) via `post-interactions2.mjs`. The comment button on a reply opens `post-feed.html?event=` in a new tab for that reply's sub-thread. ### Files to create @@ -154,33 +155,28 @@ Each `β”‚` is a vertical line drawn with CSS `border-left` on a container div. T ### Tasks -- [ ] **2.5.1** Add a `computeReplyLevels(events, rootEventId)` function to `post-feed.html` (or a shared module): +- [x] **2.5.1** Add a `computeReplyLevels(events, rootEventId)` function to `post-feed.html`: - Build a map of `eventId β†’ parentEventId` from the `e` tags (NIP-10 marked reply tags) - For each event, compute its level by walking up the parent chain to the root - Root post = level 0, direct reply = level 1, reply to reply = level 2, etc. - Handle missing parents gracefully (if a parent isn't in the fetched set, treat as level 1) - Return a `Map` -- [ ] **2.5.2** Sort replies in depth-first order (like Amethyst's `replyLevelSignature`): +- [x] **2.5.2** Sort replies in depth-first order (like Amethyst's `replyLevelSignature`): - Root post first - Then replies grouped by parent, in chronological order within each group - This makes the vertical lines visually contiguous β€” a reply's children appear right below it -- [ ] **2.5.3** Render each reply with vertical indent lines: - - Wrap each reply in a container div with `padding-left: calc(level * 12px)` (or similar) - - For each level 0..N-1, add a vertical line using `border-left: 2px solid var(--muted-color)` on a positioned div +- [x] **2.5.3** Render each reply with vertical indent lines: + - Wrap each reply in a container div with `padding-left: calc(level * 12px)` + - For each level 0..N-1, add a vertical line using positioned divs with `background: var(--muted-color)` - The last line (level N-1) uses `var(--accent-color)` if this is the focused post, otherwise `var(--muted-color)` - - Use CSS pseudo-elements or nested divs for the lines -- [ ] **2.5.4** Add collapse/expand functionality: - - Each reply has a collapse toggle (small button or click on the vertical line) - - Collapsing a reply hides all of its descendants - - Show a count of hidden replies when collapsed (e.g., "3 replies hidden") - - Collapsed state stored in a Set in memory +- [x] ~~**2.5.4** Add collapse/expand functionality~~ β€” **Removed per user request.** All replies are shown, no collapse. -- [ ] **2.5.5** Highlight the focused post: - - If the URL has a `#` hash or a `focus=` param, scroll to and highlight that post - - The highlighted post's vertical line uses `var(--accent-color)` +- [x] **2.5.5** Highlight the focused post: + - If the URL has a `focus=` param, scroll to and highlight that post + - The highlighted post's vertical line uses `var(--accent-color)` and card gets `var(--accent-color)` outline ### CSS approach (CSS variables only) diff --git a/www/css/client.css b/www/css/client.css index 74b0551..ce0ba7f 100644 --- a/www/css/client.css +++ b/www/css/client.css @@ -1388,6 +1388,68 @@ a.nostr-embed-preview-text:hover { color: var(--accent-color); } +/* Zap rail selector (Phase 4 β€” nutzap vs Lightning) */ +.zap-rail-toggle { + display: flex; + gap: 6px; +} + +.zap-rail-option { + flex: 1; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--background-color); + color: var(--muted-color); + font-family: var(--font-family); + font-size: 13px; + padding: 8px 10px; + cursor: pointer; + text-align: center; +} + +.zap-rail-option.active { + border-color: var(--accent-color); + color: var(--accent-color); +} + +.zap-rail-single { + display: block; + border: 1px solid var(--accent-color); + border-radius: 8px; + background: var(--background-color); + color: var(--accent-color); + font-family: var(--font-family); + font-size: 13px; + padding: 8px 10px; + text-align: center; +} + +.zap-selector-balance { + font-size: 12px; + color: var(--muted-color); +} + +.zap-selector-warning { + font-size: 12px; + color: var(--accent-color); + display: none; +} + +.zap-selector-mint-info { + font-size: 12px; + color: var(--muted-color); +} + +.zap-selector-error { + font-size: 13px; + color: var(--accent-color); +} + +.zap-send:disabled { + opacity: 0.5; + cursor: not-allowed; +} + /* ************************************************************************* */ /* AUTHOR HEADER */ /* ************************************************************************* */ diff --git a/www/js/post-interactions.mjs b/www/js/post-interactions.mjs index 252a780..723fd26 100644 --- a/www/js/post-interactions.mjs +++ b/www/js/post-interactions.mjs @@ -11,6 +11,7 @@ import { promptZapDetails, prepareZapInvoiceForEvent, resolveNutzapSpecForPubkey, + resolveZapCapabilities, extractZapAmount as extractZapAmountFromReceipt, extractZapComment as extractZapCommentFromReceipt } from './zaps.mjs'; @@ -957,6 +958,8 @@ let publishEventFn = null; let walletPayInvoiceFn = null; let walletSendNutzapFn = null; let walletFetchMintListFn = null; +let walletGetMintsFn = null; +let walletGetBalanceFn = null; let getRelayDataFn = null; let getUserSettingsFn = null; let patchUserSettingsFn = null; @@ -1079,7 +1082,11 @@ export function renderFooterRow(eventId, eventData, options = {}) { container.appendChild(timeEl); footerRow.appendChild(container); - + + // Async, non-blocking zap capability badges (Phase 3). + // Rendered after the bar is in the DOM so feed rendering is never blocked. + setTimeout(() => attachZapCapabilityBadges(eventId, eventData.pubkey), 0); + return footerRow; } @@ -1145,6 +1152,10 @@ export function renderInteractionBar(postId, postData, options = {}) { container.appendChild(commentItem); container.appendChild(quoteItem); + // Async, non-blocking zap capability badges (Phase 3). + // Rendered after the bar is in the DOM so feed rendering is never blocked. + setTimeout(() => attachZapCapabilityBadges(postId, postData.pubkey), 0); + return container; } @@ -1302,6 +1313,135 @@ function updateNutzapCountDisplay(el, state = {}) { el.classList.toggle('has-count', hasCount); } +// ============================================================================= +// ZAP CAPABILITY BADGES (Phase 3 β€” feed-upgrade) +// ============================================================================= +// +// Small visual indicators appended next to the zap button showing which zap +// rails are available for the post's author: +// ⚑ (accent) β€” recipient has a lud16 Lightning address +// πŸ₯œ (accent) β€” recipient has a kind 10019 mint list with a shared mint +// πŸ₯œ (muted) β€” recipient has a kind 10019 but no shared mint with sender +// +// Badges are rendered asynchronously after the interaction bar is in the DOM +// so feed rendering is never blocked. Each badge carries a `title` tooltip +// with human-readable details. Colors come exclusively from CSS variables. + +/** + * Build a single capability badge element. + * @param {Object} opts + * @param {string} opts.glyph - emoji to display (e.g. '⚑' or 'πŸ₯œ') + * @param {string} opts.colorVar - CSS variable name for color ('--accent-color' | '--muted-color') + * @param {string} opts.title - tooltip text + * @returns {HTMLSpanElement} + */ +function createZapCapabilityBadge({ glyph, colorVar, title }) { + const badge = document.createElement('span'); + badge.className = 'zap-capability-badge'; + badge.textContent = glyph; + badge.title = title; + badge.style.color = `var(${colorVar})`; + badge.style.fontSize = '75%'; + badge.style.lineHeight = '1'; + badge.style.marginLeft = '2px'; + badge.style.userSelect = 'none'; + badge.style.pointerEvents = 'none'; + badge.setAttribute('aria-hidden', 'true'); + return badge; +} + +/** + * Build the tooltip text for the nutzap capability. + * @param {Object} caps - result from resolveZapCapabilities + * @returns {string} + */ +function nutzapCapabilityTooltip(caps) { + if (caps.canNutzap && caps.sharedMints.length > 0) { + const first = caps.sharedMints[0]; + const extra = caps.sharedMints.length > 1 + ? ` (+${caps.sharedMints.length - 1} more)` + : ''; + return `Can nutzap via ${first}${extra}`; + } + if (caps.hasMintList) { + return 'No shared mints β€” Lightning only'; + } + return 'No nutzap mint list'; +} + +/** + * Append capability badges to an interaction bar for a given post. + * Looks up the bar by `data-post-id` so it works regardless of which render + * path created it (renderFooterRow or renderInteractionBar). + * + * @param {string} postId - the event id used as data-post-id + * @param {string} pubkey - the post author's pubkey + */ +function attachZapCapabilityBadges(postId, pubkey) { + if (!postId || !pubkey) return; + if (typeof walletFetchMintListFn !== 'function' && typeof fetchProfile !== 'function') { + return; // nothing to resolve with + } + + resolveZapCapabilities(pubkey, { + fetchProfile, + fetchMintList: walletFetchMintListFn || undefined, + getSenderMints: walletGetMintsFn || undefined, + logPrefix: '[post-interactions][zap-caps]' + }).then((caps) => { + const bar = document.querySelector(`.divPostInteractions[data-post-id="${postId}"]`); + if (!bar) return; // post may have been removed from the DOM + + // Avoid double-rendering if badges already exist. + if (bar.querySelector('.zap-capability-badge')) return; + + const zapItem = bar.querySelector('.interaction-item.zap'); + const anchor = zapItem || bar; // fall back to the bar itself + const host = zapItem ? zapItem : bar; + + const badges = []; + + if (caps.canLightning) { + badges.push(createZapCapabilityBadge({ + glyph: '⚑', + colorVar: '--accent-color', + title: caps.lud16 ? `Lightning: ${caps.lud16}` : 'Lightning zap available' + })); + } + + if (caps.hasMintList) { + const hasShared = caps.canNutzap && caps.sharedMints.length > 0; + badges.push(createZapCapabilityBadge({ + glyph: 'πŸ₯œ', + colorVar: hasShared ? '--accent-color' : '--muted-color', + title: nutzapCapabilityTooltip(caps) + })); + } + + if (badges.length === 0) { + // Only show an explicit "no capability" indicator on the zap item + // itself, and only when we actually had enough info to resolve + // (i.e. fetchProfile ran). This keeps the bar uncluttered when the + // wallet/ndk isn't ready yet. + if (zapItem && caps.lud16 === null && !caps.hasMintList) { + const noCap = createZapCapabilityBadge({ + glyph: '⊘', + colorVar: '--muted-color', + title: 'No zap capability' + }); + host.appendChild(noCap); + } + return; + } + + for (const badge of badges) { + host.appendChild(badge); + } + }).catch((error) => { + console.warn('[post-interactions][zap-caps] attach failed', error?.message || error); + }); +} + // ============================================================================= // ANIMATION HANDLING // ============================================================================= @@ -1323,6 +1463,66 @@ function isZapTimeoutError(error) { return msg.includes('timeout'); } +/** + * Classify a zap/melt payment error into a user-friendly message. + * Hides raw mint API errors while still being informative. + * @param {Error|*} error + * @returns {string} + */ +function friendlyZapErrorMessage(error) { + const raw = String(error?.message || error || '').trim(); + const msg = raw.toLowerCase(); + + if (msg.includes('melt') || msg.includes('swap') || msg.includes('proof')) { + return 'Mint couldn\'t route Lightning payment. The mint may not have enough Lightning liquidity.'; + } + if (msg.includes('timeout')) { + return 'Payment timed out. The mint may be slow or unresponsive.'; + } + if (msg.includes('insufficient') || msg.includes('balance')) { + return 'Insufficient Cashu balance for this payment.'; + } + return raw || 'Zap failed.'; +} + +/** + * Fetch the current user's Cashu balance in sats. + * Returns null when the wallet function is unavailable or the fetch fails. + * @returns {Promise} + */ +async function fetchWalletBalanceSats() { + if (typeof walletGetBalanceFn !== 'function') return null; + try { + const result = await walletGetBalanceFn(); + const balance = Number(result?.balance ?? result?.sats ?? result); + if (Number.isFinite(balance) && balance >= 0) { + return Math.floor(balance); + } + } catch (error) { + console.warn('[post-interactions][zap] fetchWalletBalanceSats:failed', error?.message || error); + } + return null; +} + +/** + * Refresh the wallet balance display after a zap. + * Re-fetches the balance and dispatches an `ndkWalletBalance` event so any + * footer/dialog balance displays can update. Non-throwing. + * @returns {Promise} + */ +async function refreshWalletBalanceDisplay() { + const balanceSats = await fetchWalletBalanceSats(); + if (balanceSats === null) return; + + try { + window.dispatchEvent(new CustomEvent('ndkWalletBalance', { + detail: { balanceSats, source: 'post-interactions-zap' } + })); + } catch (error) { + console.warn('[post-interactions][zap] refreshWalletBalanceDisplay:dispatch-failed', error?.message || error); + } +} + // ============================================================================= // INTERACTION HANDLERS // ============================================================================= @@ -1587,8 +1787,49 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) { throw new Error('Cashu wallet payer is not configured on this page'); } + // --- Resolve zap capabilities (Phase 4) --------------------------------- + // Determine which rails (Lightning / Nutzap) are available for this + // recipient so the dialog can present a rail selector. + let zapCaps = null; + try { + zapCaps = await resolveZapCapabilities(postPubkey, { + fetchProfile, + fetchMintList: walletFetchMintListFn || undefined, + getSenderMints: walletGetMintsFn || undefined, + logPrefix: '[post-interactions][zap-caps]' + }); + } catch (capsError) { + console.warn('[post-interactions][zap] handleZapClick:caps-failed', capsError?.message || capsError); + } + + const canLightning = Boolean(zapCaps?.canLightning); + const canNutzap = Boolean(zapCaps?.canNutzap); + const sharedMints = Array.isArray(zapCaps?.sharedMints) ? zapCaps.sharedMints : []; + + console.log('[post-interactions][zap] handleZapClick:caps', { + recipient: String(postPubkey || '').slice(0, 8) + '…', + canLightning, + canNutzap, + sharedMints: sharedMints.length + }); + + // --- Fetch the user's Cashu balance for the dialog ---------------------- + let balanceSats = null; + if (typeof walletGetBalanceFn === 'function') { + try { + const balanceResult = await walletGetBalanceFn(); + const balance = Number(balanceResult?.balance ?? balanceResult?.sats ?? balanceResult); + if (Number.isFinite(balance) && balance >= 0) { + balanceSats = Math.floor(balance); + } + } catch (balanceError) { + console.warn('[post-interactions][zap] handleZapClick:balance-failed', balanceError?.message || balanceError); + } + } + + // --- Resolve the nutzap spec (still needed for the actual send) --------- let nutzapSpec = null; - if (typeof walletFetchMintListFn === 'function') { + if (canNutzap && typeof walletFetchMintListFn === 'function') { try { nutzapSpec = await resolveNutzapSpecForPubkey(postPubkey, { fetchMintList: walletFetchMintListFn, @@ -1610,7 +1851,9 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) { defaultAmountSats: zapDefaults.amountSats, defaultComment: zapDefaults.comment, defaultShouldZap: true, - onSaveDefault: saveZapDefaultsToSettings + onSaveDefault: saveZapDefaultsToSettings, + railOptions: { canLightning, canNutzap, sharedMints }, + balanceSats }); if (!details) { console.log('[post-interactions][zap] handleZapClick:cancelled-by-user'); @@ -1620,7 +1863,8 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) { console.log('[post-interactions][zap] handleZapClick:details', { amountSats: details.amountSats, hasComment: Boolean(String(details.comment || '').trim()), - shouldZap: Boolean(details.shouldZap) + shouldZap: Boolean(details.shouldZap), + rail: details.rail }); const state = getPostState(postId); @@ -1652,9 +1896,55 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) { return; } - if (nutzapSpec?.mints?.length > 0 && typeof walletSendNutzapFn === 'function') { + // --- Pre-flight balance check (Phase 5) -------------------------------- + // A final safety net right before sending, in case the user ignored the + // dialog warning or the balance changed between dialog and send. + const zapAmount = Math.max(0, Math.floor(Number(details.amountSats || 0))); + const preflightBalance = await fetchWalletBalanceSats(); + + if (preflightBalance !== null) { + if (preflightBalance < zapAmount) { + const msg = `Insufficient balance. Current: ${preflightBalance} sats, needed: ${zapAmount} sats`; + console.warn('[post-interactions][zap] handleZapClick:insufficient-balance', { + balance: preflightBalance, needed: zapAmount + }); + if (countEl) { + countEl.textContent = 'low'; + countEl.title = msg; + setTimeout(() => { + updateZapCountDisplay(itemEl, getPostState(postId)); + }, 2200); + } + window.alert(msg); + return; + } + + // "Barely sufficient" warning: remaining balance would be less than + // 10% of the current balance. + const remaining = preflightBalance - zapAmount; + const tenPercent = Math.floor(preflightBalance * 0.10); + if (remaining > 0 && remaining < tenPercent) { + const confirmMsg = `This will use most of your balance (${remaining} sats remaining). Continue?`; + const proceed = window.confirm(confirmMsg); + if (!proceed) { + console.log('[post-interactions][zap] handleZapClick:declined-low-balance'); + return; + } + } + } + + // --- Choose the rail based on the user's selection (Phase 4) ------------ + // Prefer the explicit rail chosen in the dialog. Fall back to capability + // detection for backward compatibility when no rail was returned. + const useNutzapRail = details.rail === 'nutzap' + ? (nutzapSpec?.mints?.length > 0 && typeof walletSendNutzapFn === 'function') + : (details.rail === 'lightning' + ? false + : (nutzapSpec?.mints?.length > 0 && typeof walletSendNutzapFn === 'function')); + + if (useNutzapRail) { if (countEl) countEl.textContent = 'send…'; - console.log('[post-interactions][zap] handleZapClick:sending-nutzap'); + console.log('[post-interactions][zap] handleZapClick:sending-nutzap', { rail: details.rail }); const nutzapResult = await walletSendNutzapFn({ amount: details.amountSats, memo: details.comment, @@ -1702,15 +1992,28 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) { itemEl.classList.remove('zap-success'); updateZapCountDisplay(itemEl, state); }, 1800); + + // --- Post-zap balance refresh (Phase 5) -------------------------------- + // Refresh the wallet balance display so the user sees their updated + // balance after a successful zap. Non-blocking β€” failures are logged + // but never surface to the user since the zap itself succeeded. + refreshWalletBalanceDisplay().catch((refreshError) => { + console.warn('[post-interactions][zap] handleZapClick:balance-refresh-failed', refreshError?.message || refreshError); + }); } catch (error) { const timeoutFailure = isZapTimeoutError(error); + const friendlyMessage = friendlyZapErrorMessage(error); console.error('[post-interactions][zap] handleZapClick:failed', error); if (countEl) { countEl.textContent = timeoutFailure ? 'check' : 'fail'; + countEl.title = friendlyMessage; setTimeout(() => { updateZapCountDisplay(itemEl, getPostState(postId)); }, timeoutFailure ? 2800 : 2200); } + // Surface a user-friendly error message. Avoids exposing raw mint API + // errors while still being informative. + window.alert(friendlyMessage); itemEl.classList.remove('zap-success'); } finally { clearInterval(zapPulseInterval); @@ -2308,6 +2611,8 @@ export function initInteractions(ndkFunctions = {}) { walletPayInvoice, walletSendNutzap, walletFetchMintList, + walletGetMints, + walletGetBalance, getRelayData, getUserSettings, patchUserSettings, @@ -2325,6 +2630,8 @@ export function initInteractions(ndkFunctions = {}) { walletPayInvoiceFn = typeof walletPayInvoice === 'function' ? walletPayInvoice : null; walletSendNutzapFn = typeof walletSendNutzap === 'function' ? walletSendNutzap : null; walletFetchMintListFn = typeof walletFetchMintList === 'function' ? walletFetchMintList : null; + walletGetMintsFn = typeof walletGetMints === 'function' ? walletGetMints : null; + walletGetBalanceFn = typeof walletGetBalance === 'function' ? walletGetBalance : null; getRelayDataFn = typeof getRelayData === 'function' ? getRelayData : null; getUserSettingsFn = typeof getUserSettings === 'function' ? getUserSettings : null; patchUserSettingsFn = typeof patchUserSettings === 'function' ? patchUserSettings : null; diff --git a/www/js/post-interactions2.mjs b/www/js/post-interactions2.mjs index bfa6c6b..b767aee 100644 --- a/www/js/post-interactions2.mjs +++ b/www/js/post-interactions2.mjs @@ -14,6 +14,8 @@ export function initPostCards(deps = {}) { walletPayInvoice, walletSendNutzap, walletFetchMintList, + walletGetMints, + walletGetBalance, getRelayData, maxSubscriptions = DEFAULT_MAX_SUBSCRIPTIONS, getUserSettings, @@ -34,6 +36,8 @@ export function initPostCards(deps = {}) { walletPayInvoice, walletSendNutzap, walletFetchMintList, + walletGetMints, + walletGetBalance, getRelayData, getUserSettings, patchUserSettings, diff --git a/www/js/version.json b/www/js/version.json index 2aa9fce..a685f7d 100644 --- a/www/js/version.json +++ b/www/js/version.json @@ -1,5 +1,5 @@ { - "VERSION": "v0.7.29", - "VERSION_NUMBER": "0.7.29", - "BUILD_DATE": "2026-06-26T00:29:51.222Z" + "VERSION": "v0.7.30", + "VERSION_NUMBER": "0.7.30", + "BUILD_DATE": "2026-06-26T00:48:38.325Z" } diff --git a/www/js/zaps.mjs b/www/js/zaps.mjs index 1c99c22..edf5bbb 100644 --- a/www/js/zaps.mjs +++ b/www/js/zaps.mjs @@ -249,6 +249,40 @@ function closeActiveZapModal(result, resolver) { resolver(result); } +/** + * Escape a string for safe insertion into HTML text/attribute content. + * @param {string} value + * @returns {string} + */ +const HTML_ESCAPE_RE = /[&<>"']/g; +function escapeHtml(value) { + const amp = '&' + 'amp;'; + const lt = '&' + 'lt;'; + const gt = '&' + 'gt;'; + const quot = '&' + 'quot;'; + const apos = '&' + '#39;'; + const map = { '&': amp, '<': lt, '>': gt, '"': quot, "'": apos }; + return String(value || '').replace(HTML_ESCAPE_RE, (ch) => map[ch]); +} + +/** + * Choose the default zap rail based on availability and amount. + * - nutzap available and amount < 1000 sats β†’ nutzap + * - nutzap available and amount >= 1000 sats β†’ lightning + * - nutzap not available β†’ lightning (if available) + * - neither available β†’ null + * @param {Object} railOptions - { canLightning, canNutzap } + * @param {number} amountSats + * @returns {'nutzap'|'lightning'|null} + */ +function chooseDefaultRail(railOptions, amountSats) { + const { canLightning = false, canNutzap = false } = railOptions || {}; + if (canNutzap && Number(amountSats) < 1000) return 'nutzap'; + if (canLightning) return 'lightning'; + if (canNutzap) return 'nutzap'; + return null; +} + export function promptZapDetails(options = {}) { if (activeZapModalEl) { return Promise.resolve(null); @@ -258,9 +292,19 @@ export function promptZapDetails(options = {}) { defaultAmountSats = 21, defaultComment = '', defaultShouldZap = true, - onSaveDefault = null + onSaveDefault = null, + railOptions = null, + balanceSats = null } = options; + // Normalize rail options. + const canLightning = Boolean(railOptions?.canLightning); + const canNutzap = Boolean(railOptions?.canNutzap); + const sharedMints = Array.isArray(railOptions?.sharedMints) + ? railOptions.sharedMints.map((m) => String(m || '').trim()).filter(Boolean) + : []; + const hasAnyRail = canLightning || canNutzap; + return new Promise((resolve) => { const initialAmount = Number(defaultAmountSats); const normalizedDefaultAmount = Number.isFinite(initialAmount) && initialAmount > 0 @@ -268,11 +312,44 @@ export function promptZapDetails(options = {}) { : 21; const normalizedDefaultComment = String(defaultComment || '').trim(); + // Resolve the initial rail selection. + let selectedRail = hasAnyRail + ? chooseDefaultRail({ canLightning, canNutzap }, normalizedDefaultAmount) + : null; + + const showRailToggle = canLightning && canNutzap; + const primarySharedMint = sharedMints[0] || ''; + + const balanceDisplay = (Number.isFinite(Number(balanceSats)) && Number(balanceSats) >= 0) + ? `
Cashu balance: ${Math.floor(Number(balanceSats))} sats
` + : ''; + const noRailMessage = !hasAnyRail + ? `
Recipient cannot receive zaps
` + : ''; + + const railToggleHtml = showRailToggle + ? `
+ + +
` + : (hasAnyRail + ? `
+ ${selectedRail === 'nutzap' ? 'πŸ₯œ Nutzap' : '⚑ Lightning'} +
` + : ''); + + const sharedMintInfoHtml = (canNutzap && primarySharedMint) + ? `
Will send via ${escapeHtml(primarySharedMint)}
` + : ''; + const overlay = document.createElement('div'); overlay.className = 'zap-selector-overlay'; overlay.innerHTML = `