diff --git a/www/css/client.css b/www/css/client.css index ce0ba7f..e650ed6 100644 --- a/www/css/client.css +++ b/www/css/client.css @@ -1450,6 +1450,74 @@ a.nostr-embed-preview-text:hover { cursor: not-allowed; } +/* Nutzap recipient mint list (promptNutzapDetails) */ +.zap-selector-mint-list { + list-style: none; + margin: 4px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.zap-selector-mint-list-item { + font-size: 11px; + color: var(--muted-color); + word-break: break-all; +} + +.zap-selector-mint-list-item.shared { + color: var(--accent-color); +} + +/* Nutzap interaction item (post footer) */ +.interaction-item.nutzap { + flex: 0 0 auto; +} + +.interaction-item.nutzap .interaction-icon { + font-size: 14px; + line-height: 1; +} + +.interaction-item.nutzap.zap-pending { + color: var(--muted-color); +} + +.interaction-item.nutzap.zap-pending .interaction-icon, +.interaction-item.nutzap.zap-pending .interaction-icon.active, +.interaction-item.nutzap.zap-pending .interaction-count { + color: var(--muted-color) !important; + transition: color 240ms linear !important; +} + +.interaction-item.nutzap.zap-pending.zap-pending-accent .interaction-icon, +.interaction-item.nutzap.zap-pending.zap-pending-accent .interaction-icon.active, +.interaction-item.nutzap.zap-pending.zap-pending-accent .interaction-count { + color: var(--accent-color) !important; +} + +.interaction-item.nutzap.zap-success, +.interaction-item.nutzap.zap-success .interaction-icon, +.interaction-item.nutzap.zap-success .interaction-icon.active, +.interaction-item.nutzap.zap-success .interaction-count { + color: var(--accent-color) !important; +} + +/* Dimmed (capability unavailable) interaction items */ +.interaction-item.dimmed { + opacity: 0.45; + cursor: not-allowed; +} + +.interaction-item.dimmed:hover { + color: var(--muted-color); +} + +.interaction-item.dimmed:hover .interaction-icon { + color: var(--muted-color); +} + /* ************************************************************************* */ /* AUTHOR HEADER */ /* ************************************************************************* */ diff --git a/www/js/post-interactions.mjs b/www/js/post-interactions.mjs index 723fd26..d58c249 100644 --- a/www/js/post-interactions.mjs +++ b/www/js/post-interactions.mjs @@ -9,6 +9,7 @@ import { createProfileCache } from './profile-cache.mjs'; import { mountDotMenu } from './dot-menu.mjs'; import { promptZapDetails, + promptNutzapDetails, prepareZapInvoiceForEvent, resolveNutzapSpecForPubkey, resolveZapCapabilities, @@ -880,12 +881,13 @@ const ICON_LABELS = { like: { label: 'Like', activeLabel: 'Like' }, comment: { label: 'Comment', activeLabel: 'Comment' }, quote: { label: 'Quote', activeLabel: 'Quote' }, - zap: { label: 'Zap', activeLabel: 'Zap' } + zap: { label: 'Zap', activeLabel: 'Zap' }, + nutzap: { label: 'π₯', activeLabel: 'π₯' } }; /** * Generate HTML for interaction label text - * @param {string} type - Icon type: 'like', 'comment', 'quote', 'zap' + * @param {string} type - Icon type: 'like', 'comment', 'quote', 'zap', 'nutzap' * @param {boolean} active - Whether the label is in active/selected state * @returns {string} HTML string with label text */ @@ -1052,7 +1054,7 @@ export function renderFooterRow(eventId, eventData, options = {}) { onClick: () => handleQuoteClick(eventId, eventData.pubkey, currentPubkey, quoteItem) }); - // Zap button + // Zap button (Lightning only) const zapItem = createInteractionItem({ type: 'zap', count: state.zapTotal, @@ -1062,8 +1064,16 @@ export function renderFooterRow(eventId, eventData, options = {}) { }); updateZapCountDisplay(zapItem, state); - const nutzapCountEl = createNutzapCountDisplay(state); - + // Nutzap button (Cashu ecash) + const nutzapItem = createInteractionItem({ + type: 'nutzap', + count: state.nutzapTotal, + active: false, + title: 'Nutzap', + onClick: () => handleNutzapClick(eventId, eventData.pubkey, currentPubkey, nutzapItem) + }); + updateNutzapButtonCount(nutzapItem, state); + const midControls = document.createElement('div'); midControls.className = 'divPostMidControls'; @@ -1072,7 +1082,7 @@ export function renderFooterRow(eventId, eventData, options = {}) { midControls.appendChild(quoteItem); container.appendChild(zapItem); - container.appendChild(nutzapCountEl); + container.appendChild(nutzapItem); container.appendChild(midControls); // Time as the final item in the same row @@ -1083,9 +1093,9 @@ export function renderFooterRow(eventId, eventData, options = {}) { 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); + // Async, non-blocking capability dimming. + // Resolved after the bar is in the DOM so feed rendering is never blocked. + setTimeout(() => applyZapCapabilityDimming(eventId, eventData.pubkey, zapItem, nutzapItem), 0); return footerRow; } @@ -1133,7 +1143,7 @@ export function renderInteractionBar(postId, postData, options = {}) { onClick: () => handleQuoteClick(postId, postData.pubkey, currentPubkey, quoteItem) }); - // Zap button + // Zap button (Lightning only) const zapItem = createInteractionItem({ type: 'zap', count: state.zapTotal, @@ -1144,17 +1154,26 @@ export function renderInteractionBar(postId, postData, options = {}) { }); updateZapCountDisplay(zapItem, state); - const nutzapCountEl = createNutzapCountDisplay(state); + // Nutzap button (Cashu ecash) + const nutzapItem = createInteractionItem({ + type: 'nutzap', + count: state.nutzapTotal, + active: false, + showCount: state.nutzapTotal > 0, + title: 'Nutzap', + onClick: () => handleNutzapClick(postId, postData.pubkey, currentPubkey, nutzapItem) + }); + updateNutzapButtonCount(nutzapItem, state); container.appendChild(zapItem); - container.appendChild(nutzapCountEl); + container.appendChild(nutzapItem); container.appendChild(likeItem); 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); + // Async, non-blocking capability dimming. + // Resolved after the bar is in the DOM so feed rendering is never blocked. + setTimeout(() => applyZapCapabilityDimming(postId, postData.pubkey, zapItem, nutzapItem), 0); return container; } @@ -1205,10 +1224,10 @@ export function updateInteractionBar(postId, state) { updateZapCountDisplay(zapItem, state); } - // Update nutzap count display - const nutzapCountEl = bar.querySelector('.nutzap-count-display'); - if (nutzapCountEl) { - updateNutzapCountDisplay(nutzapCountEl, state); + // Update nutzap button count + const nutzapItem = bar.querySelector('.interaction-item.nutzap'); + if (nutzapItem) { + updateNutzapButtonCount(nutzapItem, state); } } @@ -1283,105 +1302,48 @@ function updateZapCountDisplay(itemEl, state = {}) { countEl.textContent = formatCount(zapTotal); } -function createNutzapCountDisplay(state = {}) { - const el = document.createElement('div'); - el.className = 'nutzap-count-display'; - el.title = 'Nutzaps'; - - const emojiEl = document.createElement('span'); - emojiEl.className = 'nutzap-emoji'; - emojiEl.textContent = 'π₯'; - - const countEl = document.createElement('span'); - countEl.className = 'nutzap-count-value'; - - el.appendChild(emojiEl); - el.appendChild(countEl); - - updateNutzapCountDisplay(el, state); - return el; -} - -function updateNutzapCountDisplay(el, state = {}) { - const countEl = el?.querySelector?.('.nutzap-count-value'); +/** + * Update the count display on the Nutzap interaction button. + * Mirrors updateZapCountDisplay but for the nutzap button. + * @param {HTMLElement} itemEl - The nutzap interaction item element + * @param {Object} state - The interaction state + */ +function updateNutzapButtonCount(itemEl, state = {}) { + const countEl = itemEl?.querySelector?.('.interaction-count'); if (!countEl) return; const nutzapTotal = Math.max(0, Number(state?.nutzapTotal || 0)); - const hasCount = nutzapTotal > 0; - countEl.textContent = formatCount(nutzapTotal); - el.classList.toggle('has-count', hasCount); } // ============================================================================= -// ZAP CAPABILITY BADGES (Phase 3 β feed-upgrade) +// ZAP CAPABILITY DIMMING // ============================================================================= // -// 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 +// Instead of separate capability badges, the Zap and Nutzap buttons themselves +// indicate capability via dimming: +// - Zap button dimmed when recipient has no lud16 Lightning address +// - Nutzap button dimmed when recipient has no shared mints for nutzaps // -// 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. +// Dimming is applied asynchronously after the bar is in the DOM so feed +// rendering is never blocked. 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). + * Dim the Zap and/or Nutzap buttons based on the recipient's zap capabilities. + * Resolves capabilities async and applies a `.dimmed` class + tooltip to + * buttons whose rail is unavailable. Non-throwing. * - * @param {string} postId - the event id used as data-post-id + * @param {string} postId - the event id (used for logging only) * @param {string} pubkey - the post author's pubkey + * @param {HTMLElement} zapItem - the Zap (Lightning) interaction item + * @param {HTMLElement} nutzapItem - the Nutzap interaction item */ -function attachZapCapabilityBadges(postId, pubkey) { - if (!postId || !pubkey) return; +function applyZapCapabilityDimming(postId, pubkey, zapItem, nutzapItem) { + if (!pubkey) return; if (typeof walletFetchMintListFn !== 'function' && typeof fetchProfile !== 'function') { return; // nothing to resolve with } + if (!zapItem && !nutzapItem) return; resolveZapCapabilities(pubkey, { fetchProfile, @@ -1389,56 +1351,30 @@ function attachZapCapabilityBadges(postId, pubkey) { 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); + // Elements may have been detached from the DOM by the time this resolves. + if (zapItem && zapItem.isConnected) { + if (!caps.canLightning) { + zapItem.classList.add('dimmed'); + zapItem.title = 'No Lightning address'; + } else { + zapItem.classList.remove('dimmed'); + zapItem.title = caps.lud16 ? `Lightning: ${caps.lud16}` : 'Zap'; } - return; } - for (const badge of badges) { - host.appendChild(badge); + if (nutzapItem && nutzapItem.isConnected) { + if (!caps.canNutzap) { + nutzapItem.classList.add('dimmed'); + nutzapItem.title = caps.hasMintList + ? 'No shared mints for nutzap' + : 'No shared mints for nutzap'; + } else { + nutzapItem.classList.remove('dimmed'); + nutzapItem.title = 'Nutzap'; + } } }).catch((error) => { - console.warn('[post-interactions][zap-caps] attach failed', error?.message || error); + console.warn('[post-interactions][zap-caps] dimming failed', error?.message || error); }); } @@ -1758,6 +1694,18 @@ async function saveZapDefaultsToSettings({ amountSats, comment }) { }); } +/** + * Handle Zap (Lightning) button click. + * + * Sends a Lightning zap via Cashu melt: create a LN invoice from the + * recipient's lud16, then pay it via walletPayInvoiceFn. No rail selector β + * nutzaps have their own button and handler. + * + * @param {string} postId - Post ID + * @param {string} postPubkey - Post author's pubkey + * @param {string} currentPubkey - Current user's pubkey + * @param {HTMLElement} itemEl - The interaction item element + */ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) { if (!postId || !postPubkey || !itemEl) return; if (itemEl.dataset.busy === '1') return; @@ -1783,36 +1731,10 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) { let paymentSucceeded = false; try { - if (typeof walletPayInvoiceFn !== 'function' && typeof walletSendNutzapFn !== 'function') { - throw new Error('Cashu wallet payer is not configured on this page'); + if (typeof walletPayInvoiceFn !== 'function') { + throw new Error('Lightning zap unavailable (walletPayInvoice not configured)'); } - // --- 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') { @@ -1827,32 +1749,12 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) { } } - // --- Resolve the nutzap spec (still needed for the actual send) --------- - let nutzapSpec = null; - if (canNutzap && typeof walletFetchMintListFn === 'function') { - try { - nutzapSpec = await resolveNutzapSpecForPubkey(postPubkey, { - fetchMintList: walletFetchMintListFn, - logPrefix: '[post-interactions][nutzap]' - }); - } catch (_nutzapDiscoveryError) { - nutzapSpec = null; - } - } - - console.log('[post-interactions][zap] handleZapClick:nutzap-support', { - recipient: String(postPubkey || '').slice(0, 8) + 'β¦', - hasNutzapSupport: Boolean(nutzapSpec?.mints?.length), - mintCount: Array.isArray(nutzapSpec?.mints) ? nutzapSpec.mints.length : 0 - }); - const zapDefaults = await getZapDefaultsFromSettings(); const details = await promptZapDetails({ defaultAmountSats: zapDefaults.amountSats, defaultComment: zapDefaults.comment, defaultShouldZap: true, onSaveDefault: saveZapDefaultsToSettings, - railOptions: { canLightning, canNutzap, sharedMints }, balanceSats }); if (!details) { @@ -1863,8 +1765,7 @@ 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), - rail: details.rail + shouldZap: Boolean(details.shouldZap) }); const state = getPostState(postId); @@ -1933,48 +1834,21 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) { } } - // --- 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')); + // --- Create the LN invoice and pay it via Cashu melt ------------------- + const { invoice } = await prepareZapInvoiceForEvent({ + eventId: postId, + recipientPubkey: postPubkey, + amountSats: details.amountSats, + comment: details.comment, + fetchProfile, + getRelayData: getRelayDataFn, + logPrefix: '[post-interactions][zap]' + }); - if (useNutzapRail) { - if (countEl) countEl.textContent = 'sendβ¦'; - console.log('[post-interactions][zap] handleZapClick:sending-nutzap', { rail: details.rail }); - const nutzapResult = await walletSendNutzapFn({ - amount: details.amountSats, - memo: details.comment, - targetPubkey: postPubkey, - eventId: postId, - recipientMints: nutzapSpec.mints, - recipientP2pk: nutzapSpec.p2pk, - recipientRelays: nutzapSpec.relays - }); - console.log('[post-interactions][zap] handleZapClick:nutzap-ok', nutzapResult || {}); - } else { - if (typeof walletPayInvoiceFn !== 'function') { - throw new Error('LN zap fallback unavailable (walletPayInvoice missing)'); - } - - const { invoice } = await prepareZapInvoiceForEvent({ - eventId: postId, - recipientPubkey: postPubkey, - amountSats: details.amountSats, - comment: details.comment, - fetchProfile, - getRelayData: getRelayDataFn, - logPrefix: '[post-interactions][zap]' - }); - - if (countEl) countEl.textContent = 'payβ¦'; - console.log('[post-interactions][zap] handleZapClick:paying-invoice'); - const paymentResult = await walletPayInvoiceFn(invoice); - console.log('[post-interactions][zap] handleZapClick:payment-ok', paymentResult || {}); - } + if (countEl) countEl.textContent = 'payβ¦'; + console.log('[post-interactions][zap] handleZapClick:paying-invoice'); + const paymentResult = await walletPayInvoiceFn(invoice); + console.log('[post-interactions][zap] handleZapClick:payment-ok', paymentResult || {}); paymentSucceeded = true; itemEl.classList.remove('zap-pending', 'zap-pending-accent'); @@ -2031,6 +1905,255 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) { } } +/** + * Handle Nutzap (Cashu ecash) button click. + * + * Checks the recipient's kind 10019 for shared mints, shows a nutzap-specific + * dialog (with the recipient's accepted mints), and sends ecash via + * walletSendNutzapFn. Mirrors handleZapClick's UI feedback (pulse animation, + * count update, balance check). + * + * @param {string} postId - Post ID + * @param {string} postPubkey - Post author's pubkey + * @param {string} currentPubkey - Current user's pubkey + * @param {HTMLElement} itemEl - The nutzap interaction item element + */ +async function handleNutzapClick(postId, postPubkey, currentPubkey, itemEl) { + if (!postId || !postPubkey || !itemEl) return; + if (itemEl.dataset.busy === '1') return; + + const iconContainer = itemEl.querySelector('.interaction-icon-container'); + const countEl = itemEl.querySelector('.interaction-count'); + + console.log('[post-interactions][nutzap] handleNutzapClick:start', { + postId: String(postId || '').slice(0, 8) + 'β¦', + recipient: String(postPubkey || '').slice(0, 8) + 'β¦' + }); + + itemEl.dataset.busy = '1'; + itemEl.classList.add('zap-pending'); + updateIconState(iconContainer, 'nutzap', true); + + let pendingAccent = false; + const zapPulseInterval = setInterval(() => { + pendingAccent = !pendingAccent; + itemEl.classList.toggle('zap-pending-accent', pendingAccent); + }, 500); + + let paymentSucceeded = false; + + try { + if (typeof walletSendNutzapFn !== 'function') { + throw new Error('Nutzap unavailable (walletSendNutzap not configured)'); + } + + // --- Resolve zap capabilities to confirm nutzap is possible ------------- + let zapCaps = null; + try { + zapCaps = await resolveZapCapabilities(postPubkey, { + fetchProfile, + fetchMintList: walletFetchMintListFn || undefined, + getSenderMints: walletGetMintsFn || undefined, + logPrefix: '[post-interactions][nutzap-caps]' + }); + } catch (capsError) { + console.warn('[post-interactions][nutzap] handleNutzapClick:caps-failed', capsError?.message || capsError); + } + + const canNutzap = Boolean(zapCaps?.canNutzap); + const sharedMints = Array.isArray(zapCaps?.sharedMints) ? zapCaps.sharedMints : []; + const recipientMints = Array.isArray(zapCaps?.nutzapMints) ? zapCaps.nutzapMints : []; + + console.log('[post-interactions][nutzap] handleNutzapClick:caps', { + recipient: String(postPubkey || '').slice(0, 8) + 'β¦', + canNutzap, + sharedMints: sharedMints.length, + recipientMints: recipientMints.length + }); + + if (!canNutzap || sharedMints.length === 0) { + window.alert('Recipient cannot receive nutzaps (no shared mints)'); + console.log('[post-interactions][nutzap] handleNutzapClick:no-shared-mints'); + return; + } + + // --- Resolve the nutzap spec (mints, p2pk, relays) for the send -------- + let nutzapSpec = null; + try { + nutzapSpec = await resolveNutzapSpecForPubkey(postPubkey, { + fetchMintList: walletFetchMintListFn, + logPrefix: '[post-interactions][nutzap]' + }); + } catch (nutzapDiscoveryError) { + console.warn('[post-interactions][nutzap] handleNutzapClick:spec-failed', nutzapDiscoveryError?.message || nutzapDiscoveryError); + window.alert('Recipient cannot receive nutzaps (no shared mints)'); + return; + } + + if (!nutzapSpec?.mints?.length) { + window.alert('Recipient cannot receive nutzaps (no shared mints)'); + return; + } + + // --- 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][nutzap] handleNutzapClick:balance-failed', balanceError?.message || balanceError); + } + } + + const zapDefaults = await getZapDefaultsFromSettings(); + const details = await promptNutzapDetails({ + defaultAmountSats: zapDefaults.amountSats, + defaultComment: zapDefaults.comment, + defaultShouldZap: true, + onSaveDefault: saveZapDefaultsToSettings, + balanceSats, + recipientMints: nutzapSpec.mints, + sharedMints + }); + if (!details) { + console.log('[post-interactions][nutzap] handleNutzapClick:cancelled-by-user'); + return; + } + + console.log('[post-interactions][nutzap] handleNutzapClick:details', { + amountSats: details.amountSats, + hasComment: Boolean(String(details.comment || '').trim()), + shouldZap: Boolean(details.shouldZap) + }); + + const state = getPostState(postId); + if (!state.userLiked && currentPubkey) { + const interactionBar = itemEl.closest('.divPostInteractions'); + const likeItem = interactionBar?.querySelector('.interaction-item.like'); + if (likeItem) { + try { + await handleLikeClick(postId, postPubkey, currentPubkey, likeItem); + console.log('[post-interactions][nutzap] handleNutzapClick:auto-like:ok'); + } catch (likeError) { + console.error('[post-interactions][nutzap] handleNutzapClick:auto-like:failed', likeError); + } + } + } + + const shouldZap = details.shouldZap !== false; + if (!shouldZap) { + paymentSucceeded = true; + itemEl.classList.remove('zap-pending', 'zap-pending-accent'); + if (countEl) { + countEl.textContent = ''; + setTimeout(() => { + updateNutzapButtonCount(itemEl, getPostState(postId)); + }, 150); + } + return; + } + + // --- Pre-flight balance check (Phase 5) -------------------------------- + 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][nutzap] handleNutzapClick:insufficient-balance', { + balance: preflightBalance, needed: zapAmount + }); + if (countEl) { + countEl.textContent = 'low'; + countEl.title = msg; + setTimeout(() => { + updateNutzapButtonCount(itemEl, getPostState(postId)); + }, 2200); + } + window.alert(msg); + return; + } + + 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][nutzap] handleNutzapClick:declined-low-balance'); + return; + } + } + } + + // --- Send the nutzap (Cashu ecash) ------------------------------------- + if (countEl) countEl.textContent = 'sendβ¦'; + console.log('[post-interactions][nutzap] handleNutzapClick:sending-nutzap'); + const nutzapResult = await walletSendNutzapFn({ + amount: details.amountSats, + memo: details.comment, + targetPubkey: postPubkey, + eventId: postId, + recipientMints: nutzapSpec.mints, + recipientP2pk: nutzapSpec.p2pk, + recipientRelays: nutzapSpec.relays + }); + console.log('[post-interactions][nutzap] handleNutzapClick:nutzap-ok', nutzapResult || {}); + + paymentSucceeded = true; + itemEl.classList.remove('zap-pending', 'zap-pending-accent'); + itemEl.classList.add('zap-success'); + + if (countEl) countEl.textContent = 'sent'; + + const sentAmount = Math.max(0, Math.floor(Number(details.amountSats || 0))); + if (sentAmount > 0) { + state.nutzapTotal = Math.max(0, Number(state.nutzapTotal || 0)) + sentAmount; + updateNutzapButtonCount(itemEl, state); + } + + setTimeout(() => { + itemEl.classList.remove('zap-success'); + updateNutzapButtonCount(itemEl, state); + }, 1800); + + // --- Post-nutzap balance refresh --------------------------------------- + refreshWalletBalanceDisplay().catch((refreshError) => { + console.warn('[post-interactions][nutzap] handleNutzapClick:balance-refresh-failed', refreshError?.message || refreshError); + }); + } catch (error) { + const timeoutFailure = isZapTimeoutError(error); + const friendlyMessage = friendlyZapErrorMessage(error); + console.error('[post-interactions][nutzap] handleNutzapClick:failed', error); + if (countEl) { + countEl.textContent = timeoutFailure ? 'check' : 'fail'; + countEl.title = friendlyMessage; + setTimeout(() => { + updateNutzapButtonCount(itemEl, getPostState(postId)); + }, timeoutFailure ? 2800 : 2200); + } + window.alert(friendlyMessage); + itemEl.classList.remove('zap-success'); + } finally { + clearInterval(zapPulseInterval); + itemEl.dataset.busy = '0'; + + if (!paymentSucceeded) { + itemEl.classList.remove('active', 'zap-pending', 'zap-pending-accent', 'zap-success'); + updateIconState(iconContainer, 'nutzap', false); + } else { + itemEl.classList.remove('active', 'zap-pending-accent'); + updateIconState(iconContainer, 'nutzap', true); + } + + console.log('[post-interactions][nutzap] handleNutzapClick:finish'); + } +} + /** * Update the count display for an interaction item * @param {HTMLElement} itemEl - The interaction item element diff --git a/www/js/version.json b/www/js/version.json index 9f8bde8..81b4bb1 100644 --- a/www/js/version.json +++ b/www/js/version.json @@ -1,5 +1,5 @@ { - "VERSION": "v0.7.33", - "VERSION_NUMBER": "0.7.33", - "BUILD_DATE": "2026-06-26T01:02:21.256Z" + "VERSION": "v0.7.34", + "VERSION_NUMBER": "0.7.34", + "BUILD_DATE": "2026-06-26T01:13:55.033Z" } diff --git a/www/js/zaps.mjs b/www/js/zaps.mjs index edf5bbb..0f18434 100644 --- a/www/js/zaps.mjs +++ b/www/js/zaps.mjs @@ -266,23 +266,20 @@ function escapeHtml(value) { } /** - * 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} + * Lightning-only zap details dialog. + * + * Shows amount input, preset amounts, Cashu balance, and an insufficient-balance + * warning. No rail selector β Lightning is the only rail here. Nutzaps have + * their own dialog (`promptNutzapDetails`) and their own button. + * + * @param {Object} options + * @param {number} [options.defaultAmountSats=21] + * @param {string} [options.defaultComment=''] + * @param {boolean} [options.defaultShouldZap=true] + * @param {Function} [options.onSaveDefault] + * @param {number|null} [options.balanceSats=null] + * @returns {Promise<{amountSats:number, comment:string, shouldZap:boolean}|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); @@ -293,18 +290,9 @@ export function promptZapDetails(options = {}) { defaultComment = '', defaultShouldZap = true, 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 @@ -312,44 +300,16 @@ 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) ? `