Compare commits

...

6 Commits

Author SHA1 Message Date
Laan Tungir
fa0859dcdd Fix post-feed.html: reply composer width — add layout:'inline' to mountComposer
The composer wrapper defaults to width:60% (post-composer.css). Adding
layout:'inline' applies the --inline class which sets width:100%.
2026-06-25 21:29:31 -04:00
Laan Tungir
0c340316ae Fix post-feed.html: reply composer width — add width:100% and box-sizing:border-box to .topPostComposerInput 2026-06-25 21:22:38 -04:00
Laan Tungir
313a55992f Change Nutzap button label from peanut emoji to text 'Nutzap' 2026-06-25 21:18:47 -04:00
Laan Tungir
ece3a136c1 Redesign: Split Zap into two buttons (Zap + Nutzap) with separate counts and popups
- Split single Zap button into Zap ( Lightning) and Nutzap (🥜 Cashu) buttons
- Each button has its own count display and popup dialog
- Zap button: only sends Lightning zaps via Cashu melt, simplified dialog (no rail selector)
- Nutzap button: sends NIP-61 nutzaps, dialog shows recipient's accepted mints
- Removed capability badges (buttons themselves show capability via dimming)
- Removed separate nutzap count display (count now on Nutzap button)
- Added applyZapCapabilityDimming() — dims buttons based on resolveZapCapabilities
- Added promptNutzapDetails() to zaps.mjs — shows recipient mints and shared mint info
- Simplified promptZapDetails() — removed rail selector, Lightning only
- Added .interaction-item.nutzap and .dimmed CSS styles
- All colors use CSS variables only
2026-06-25 21:13:55 -04:00
Laan Tungir
543ae6a1b3 Fix feed.html: disable inline comment rendering that caused flash of replies
Two fixes:
1. post-interactions2.mjs: only call addCommentToPost when getCommentsVisible() is true
2. feed.html: call cards.setCommentsVisible(false) after initPostCards to disable
   inline comment rendering for the new simplified feed

The old feed (feed-old.html) still has the Comments toggle and can re-enable them.
2026-06-25 21:02:21 -04:00
Laan Tungir
508952a1ea Rename feed.html -> feed-old.html, feed2.html -> feed.html
The new simplified feed (formerly feed2.html) is now the primary feed page.
Updated post-feed.html back button to point to feed.html instead of feed2.html.
2026-06-25 20:57:34 -04:00
8 changed files with 708 additions and 369 deletions

View File

@@ -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 */
/* ************************************************************************* */

View File

@@ -87,7 +87,9 @@
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox">
<button id="commentsToggleButton">Comments</button>
</div>
<div id="divFooterRight" class="divFooterBox"></div>
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
</div>
@@ -155,8 +157,6 @@
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
walletGetBalance,
walletGetMints,
getRelayData,
getUserSettings,
patchUserSettings,
@@ -281,10 +281,18 @@ import { initPostCards } from './js/post-interactions2.mjs';
return entry;
}
function handleComposerCommentIntent({ postId }) {
if (!postId) return false;
window.open('./post-feed.html?event=' + encodeURIComponent(postId), '_blank');
return true;
function handleComposerCommentIntent({ postId, postPubkey }) {
const postEl = document.querySelector(`.divPostItem[data-post-id="${postId}"]`);
if (!postEl) return false;
const entry = getOrCreateInlineComposer(postId, postEl);
if (!entry) return false;
entry.tags = [
['e', postId, '', 'reply'],
['p', postPubkey]
];
if (entry.instance?.show) entry.instance.show();
return focusInlineComposer(entry.hostEl, '');
}
function handleComposerQuoteIntent({ postId, postPubkey }) {
@@ -327,14 +335,11 @@ import { initPostCards } from './js/post-interactions2.mjs';
publishEvent,
getPubkey,
ndkFetchEvents,
queryCache,
fetchCachedProfile,
storeProfile,
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
walletGetBalance,
walletGetMints,
getRelayData,
getUserSettings,
patchUserSettings,
@@ -634,6 +639,12 @@ import { initPostCards } from './js/post-interactions2.mjs';
document.getElementById('logoutButton')?.addEventListener('click', logout);
document.getElementById('commentsToggleButton')?.addEventListener('click', () => {
const nextVisible = !cards.getCommentsVisible();
cards.setCommentsVisible(nextVisible);
document.getElementById('commentsToggleButton')?.classList.toggle('dimmed', !nextVisible);
});
btnSeeMore.addEventListener('click', () => {
displayCount += SEE_MORE_INCREMENT;
renderFeed();
@@ -709,7 +720,7 @@ import { initPostCards } from './js/post-interactions2.mjs';
} catch (error) {
console.error('[feed.html] Initialization failed:', error);
divBody.innerHTML = `<div style="text-align:center;padding:50px;"><div style="font-size:24px;margin-bottom:20px;color:var(--primary-color);">❌ Error</div><div style="font-size:16px;color:var(--muted-color);">${error.message}</div></div>`;
divBody.innerHTML = `<div style="text-align:center;padding:50px;"><div style="font-size:24px;margin-bottom:20px;color:red;">❌ Error</div><div style="font-size:16px;">${error.message}</div></div>`;
}
})();
</script>

View File

@@ -87,9 +87,7 @@
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox">
<button id="commentsToggleButton">Comments</button>
</div>
<div id="divFooterCenter" class="divFooterBox"></div>
<div id="divFooterRight" class="divFooterBox"></div>
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
</div>
@@ -157,6 +155,8 @@
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
walletGetBalance,
walletGetMints,
getRelayData,
getUserSettings,
patchUserSettings,
@@ -281,18 +281,10 @@ import { initPostCards } from './js/post-interactions2.mjs';
return entry;
}
function handleComposerCommentIntent({ postId, postPubkey }) {
const postEl = document.querySelector(`.divPostItem[data-post-id="${postId}"]`);
if (!postEl) return false;
const entry = getOrCreateInlineComposer(postId, postEl);
if (!entry) return false;
entry.tags = [
['e', postId, '', 'reply'],
['p', postPubkey]
];
if (entry.instance?.show) entry.instance.show();
return focusInlineComposer(entry.hostEl, '');
function handleComposerCommentIntent({ postId }) {
if (!postId) return false;
window.open('./post-feed.html?event=' + encodeURIComponent(postId), '_blank');
return true;
}
function handleComposerQuoteIntent({ postId, postPubkey }) {
@@ -335,11 +327,14 @@ import { initPostCards } from './js/post-interactions2.mjs';
publishEvent,
getPubkey,
ndkFetchEvents,
queryCache,
fetchCachedProfile,
storeProfile,
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
walletGetBalance,
walletGetMints,
getRelayData,
getUserSettings,
patchUserSettings,
@@ -348,6 +343,12 @@ import { initPostCards } from './js/post-interactions2.mjs';
onMuteIntent: handleMuteIntent
});
// Disable inline comment rendering — the new feed shows posts only.
// Comments are viewed on the post-feed.html thread page (new tab).
if (typeof cards.setCommentsVisible === 'function') {
cards.setCommentsVisible(false);
}
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
@@ -639,12 +640,6 @@ import { initPostCards } from './js/post-interactions2.mjs';
document.getElementById('logoutButton')?.addEventListener('click', logout);
document.getElementById('commentsToggleButton')?.addEventListener('click', () => {
const nextVisible = !cards.getCommentsVisible();
cards.setCommentsVisible(nextVisible);
document.getElementById('commentsToggleButton')?.classList.toggle('dimmed', !nextVisible);
});
btnSeeMore.addEventListener('click', () => {
displayCount += SEE_MORE_INCREMENT;
renderFeed();
@@ -720,7 +715,7 @@ import { initPostCards } from './js/post-interactions2.mjs';
} catch (error) {
console.error('[feed.html] Initialization failed:', error);
divBody.innerHTML = `<div style="text-align:center;padding:50px;"><div style="font-size:24px;margin-bottom:20px;color:red;">❌ Error</div><div style="font-size:16px;">${error.message}</div></div>`;
divBody.innerHTML = `<div style="text-align:center;padding:50px;"><div style="font-size:24px;margin-bottom:20px;color:var(--primary-color);">❌ Error</div><div style="font-size:16px;color:var(--muted-color);">${error.message}</div></div>`;
}
})();
</script>

View File

@@ -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: 'Nutzap', activeLabel: 'Nutzap' }
};
/**
* 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

View File

@@ -69,7 +69,9 @@ export function initPostCards(deps = {}) {
function handleUpdate(postId, state, event, currentPubkey) {
interactions.updateInteractionBar(postId, state);
if (event && event.kind === 1 && interactions.isCommentEvent?.(event, postId)) {
// Only render inline comments if comments are visible (the new feed
// sets commentsVisible=false to avoid inline reply rendering).
if (event && event.kind === 1 && interactions.isCommentEvent?.(event, postId) && interactions.getCommentsVisible?.()) {
interactions.addCommentToPost(postId, {
id: event.id,
pubkey: event.pubkey,

View File

@@ -1,5 +1,5 @@
{
"VERSION": "v0.7.31",
"VERSION_NUMBER": "0.7.31",
"BUILD_DATE": "2026-06-26T00:55:02.798Z"
"VERSION": "v0.7.37",
"VERSION_NUMBER": "0.7.37",
"BUILD_DATE": "2026-06-26T01:29:31.321Z"
}

View File

@@ -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)
? `<div class="zap-selector-balance">Cashu balance: ${Math.floor(Number(balanceSats))} sats</div>`
: '';
const noRailMessage = !hasAnyRail
? `<div class="zap-selector-error">Recipient cannot receive zaps</div>`
: '';
const railToggleHtml = showRailToggle
? `<div class="zap-rail-toggle" role="group" aria-label="Zap rail">
<button type="button" class="zap-rail-option" data-rail="nutzap">🥜 Nutzap</button>
<button type="button" class="zap-rail-option" data-rail="lightning">⚡ Lightning</button>
</div>`
: (hasAnyRail
? `<div class="zap-rail-single zap-rail-option active" data-rail="${selectedRail === 'nutzap' ? 'nutzap' : 'lightning'}">
${selectedRail === 'nutzap' ? '🥜 Nutzap' : '⚡ Lightning'}
</div>`
: '');
const sharedMintInfoHtml = (canNutzap && primarySharedMint)
? `<div class="zap-selector-mint-info" data-rail-info="nutzap">Will send via ${escapeHtml(primarySharedMint)}</div>`
: '';
const overlay = document.createElement('div');
overlay.className = 'zap-selector-overlay';
overlay.innerHTML = `
<div class="zap-selector" role="dialog" aria-modal="true" aria-label="Send zap">
<div class="zap-selector-title">Send Zap</div>
${noRailMessage}
<div class="zap-selector" role="dialog" aria-modal="true" aria-label="Send Lightning zap">
<div class="zap-selector-title">⚡ Lightning Zap</div>
${balanceDisplay}
${railToggleHtml}
<div class="zap-selector-presets">
<button type="button" class="zap-preset" data-amount="21">⚡21</button>
<button type="button" class="zap-preset" data-amount="100">⚡100</button>
@@ -362,7 +322,6 @@ export function promptZapDetails(options = {}) {
<input class="zap-selector-amount" type="number" min="1" step="1" value="${normalizedDefaultAmount}" />
</label>
<div class="zap-selector-warning" data-warning="insufficient-balance"></div>
${sharedMintInfoHtml}
<label class="zap-selector-label">
Comment (optional)
<textarea class="zap-selector-comment" rows="3" placeholder="Nice post ⚡">${escapeHtml(normalizedDefaultComment)}</textarea>
@@ -374,7 +333,7 @@ export function promptZapDetails(options = {}) {
<div class="zap-selector-actions">
<button type="button" class="zap-cancel">Cancel</button>
<button type="button" class="zap-save-default">Save as default</button>
<button type="button" class="zap-send" ${hasAnyRail ? '' : 'disabled'}>Send</button>
<button type="button" class="zap-send">Send</button>
</div>
</div>
`;
@@ -386,9 +345,7 @@ export function promptZapDetails(options = {}) {
const cancelBtn = overlay.querySelector('.zap-cancel');
const enableZapEl = overlay.querySelector('.zap-selector-enable');
const presetButtons = Array.from(overlay.querySelectorAll('.zap-preset'));
const railOptionButtons = Array.from(overlay.querySelectorAll('.zap-rail-option'));
const warningEl = overlay.querySelector('[data-warning="insufficient-balance"]');
const mintInfoEl = overlay.querySelector('[data-rail-info="nutzap"]');
const parseCurrentValues = () => {
const amountSats = Number(amountEl?.value || 0);
@@ -397,18 +354,6 @@ export function promptZapDetails(options = {}) {
return { amountSats, comment, shouldZap };
};
const updateRailSelection = (rail) => {
if (!rail || !hasAnyRail) return;
selectedRail = rail;
railOptionButtons.forEach((btn) => {
btn.classList.toggle('active', btn.dataset?.rail === rail);
});
// Show/hide shared mint info based on selected rail.
if (mintInfoEl) {
mintInfoEl.style.display = (rail === 'nutzap') ? '' : 'none';
}
};
const updateBalanceWarning = () => {
if (!warningEl) return;
const { amountSats } = parseCurrentValues();
@@ -426,11 +371,6 @@ export function promptZapDetails(options = {}) {
if (amountEl && button?.dataset?.amount) {
amountEl.value = button.dataset.amount;
}
// Re-evaluate default rail when amount changes via preset.
if (showRailToggle) {
const newDefault = chooseDefaultRail({ canLightning, canNutzap }, Number(button?.dataset?.amount || 0));
if (newDefault) updateRailSelection(newDefault);
}
updateBalanceWarning();
};
@@ -440,24 +380,9 @@ export function promptZapDetails(options = {}) {
amountEl?.addEventListener('input', () => {
presetButtons.forEach((btn) => btn.classList.remove('active'));
// Re-evaluate default rail as the user types.
if (showRailToggle) {
const { amountSats } = parseCurrentValues();
const newDefault = chooseDefaultRail({ canLightning, canNutzap }, amountSats);
if (newDefault) updateRailSelection(newDefault);
}
updateBalanceWarning();
});
railOptionButtons.forEach((button) => {
button.addEventListener('click', () => {
const rail = button?.dataset?.rail;
if (rail === 'nutzap' || rail === 'lightning') {
updateRailSelection(rail);
}
});
});
saveDefaultBtn?.addEventListener('click', async () => {
const { amountSats, comment } = parseCurrentValues();
if (!Number.isFinite(amountSats) || amountSats <= 0) {
@@ -490,13 +415,227 @@ export function promptZapDetails(options = {}) {
});
sendBtn?.addEventListener('click', () => {
if (!hasAnyRail) return;
const { amountSats, comment, shouldZap } = parseCurrentValues();
if (shouldZap && (!Number.isFinite(amountSats) || amountSats <= 0)) {
amountEl?.focus();
return;
}
closeActiveZapModal({ amountSats, comment, shouldZap, rail: selectedRail }, resolve);
closeActiveZapModal({ amountSats, comment, shouldZap }, resolve);
});
cancelBtn?.addEventListener('click', () => closeActiveZapModal(null, resolve));
overlay.addEventListener('click', (event) => {
if (event.target === overlay) closeActiveZapModal(null, resolve);
});
const onEscape = (event) => {
if (event.key !== 'Escape') return;
document.removeEventListener('keydown', onEscape);
closeActiveZapModal(null, resolve);
};
document.addEventListener('keydown', onEscape);
activeZapModalEl = overlay;
document.body.appendChild(overlay);
const defaultPreset = presetButtons.find((btn) => Number(btn.dataset?.amount || 0) === normalizedDefaultAmount);
if (defaultPreset) {
selectPreset(defaultPreset);
} else {
presetButtons.forEach((btn) => btn.classList.remove('active'));
}
updateBalanceWarning();
amountEl?.focus();
});
}
/**
* Nutzap (Cashu ecash) details dialog.
*
* Shows amount input, preset amounts, Cashu balance, the recipient's accepted
* mints (from kind 10019), and which shared mint will be used to send the
* ecash. Returns the user's choices or null if cancelled.
*
* @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]
* @param {string[]} [options.recipientMints=[]] - all mints on recipient's kind 10019
* @param {string[]} [options.sharedMints=[]] - mints shared with sender's wallet
* @returns {Promise<{amountSats:number, comment:string, shouldZap:boolean}|null>}
*/
export function promptNutzapDetails(options = {}) {
if (activeZapModalEl) {
return Promise.resolve(null);
}
const {
defaultAmountSats = 21,
defaultComment = '',
defaultShouldZap = true,
onSaveDefault = null,
balanceSats = null,
recipientMints = [],
sharedMints = []
} = options;
const normalizedRecipientMints = Array.isArray(recipientMints)
? recipientMints.map((m) => String(m || '').trim()).filter(Boolean)
: [];
const normalizedSharedMints = Array.isArray(sharedMints)
? sharedMints.map((m) => String(m || '').trim()).filter(Boolean)
: [];
const primarySharedMint = normalizedSharedMints[0] || '';
return new Promise((resolve) => {
const initialAmount = Number(defaultAmountSats);
const normalizedDefaultAmount = Number.isFinite(initialAmount) && initialAmount > 0
? Math.floor(initialAmount)
: 21;
const normalizedDefaultComment = String(defaultComment || '').trim();
const balanceDisplay = (Number.isFinite(Number(balanceSats)) && Number(balanceSats) >= 0)
? `<div class="zap-selector-balance">Cashu balance: ${Math.floor(Number(balanceSats))} sats</div>`
: '';
const sharedMintInfoHtml = primarySharedMint
? `<div class="zap-selector-mint-info">Will send via ${escapeHtml(primarySharedMint)}</div>`
: '';
const recipientMintsListHtml = normalizedRecipientMints.length > 0
? `<div class="zap-selector-mint-info">
<div>Recipient accepts mints:</div>
<ul class="zap-selector-mint-list">
${normalizedRecipientMints.map((m) => {
const isShared = normalizedSharedMints.some(
(s) => s.replace(/\/+$/, '').toLowerCase() === m.replace(/\/+$/, '').toLowerCase()
);
return `<li class="zap-selector-mint-list-item${isShared ? ' shared' : ''}">${escapeHtml(m)}${isShared ? ' ✓' : ''}</li>`;
}).join('')}
</ul>
</div>`
: '';
const overlay = document.createElement('div');
overlay.className = 'zap-selector-overlay';
overlay.innerHTML = `
<div class="zap-selector" role="dialog" aria-modal="true" aria-label="Send nutzap">
<div class="zap-selector-title">🥜 Nutzap</div>
${balanceDisplay}
<div class="zap-selector-presets">
<button type="button" class="zap-preset" data-amount="21">🥜21</button>
<button type="button" class="zap-preset" data-amount="100">🥜100</button>
<button type="button" class="zap-preset" data-amount="500">🥜500</button>
<button type="button" class="zap-preset" data-amount="1000">🥜1K</button>
<button type="button" class="zap-preset" data-amount="5000">🥜5K</button>
</div>
<label class="zap-selector-label">
Amount (sats)
<input class="zap-selector-amount" type="number" min="1" step="1" value="${normalizedDefaultAmount}" />
</label>
<div class="zap-selector-warning" data-warning="insufficient-balance"></div>
${sharedMintInfoHtml}
${recipientMintsListHtml}
<label class="zap-selector-label">
Comment (optional)
<textarea class="zap-selector-comment" rows="3" placeholder="Nice post 🥜">${escapeHtml(normalizedDefaultComment)}</textarea>
</label>
<label class="zap-selector-label" style="display:flex;align-items:center;gap:8px;">
<input class="zap-selector-enable" type="checkbox" ${defaultShouldZap ? 'checked' : ''} />
Send nutzap
</label>
<div class="zap-selector-actions">
<button type="button" class="zap-cancel">Cancel</button>
<button type="button" class="zap-save-default">Save as default</button>
<button type="button" class="zap-send">Send</button>
</div>
</div>
`;
const amountEl = overlay.querySelector('.zap-selector-amount');
const commentEl = overlay.querySelector('.zap-selector-comment');
const sendBtn = overlay.querySelector('.zap-send');
const saveDefaultBtn = overlay.querySelector('.zap-save-default');
const cancelBtn = overlay.querySelector('.zap-cancel');
const enableZapEl = overlay.querySelector('.zap-selector-enable');
const presetButtons = Array.from(overlay.querySelectorAll('.zap-preset'));
const warningEl = overlay.querySelector('[data-warning="insufficient-balance"]');
const parseCurrentValues = () => {
const amountSats = Number(amountEl?.value || 0);
const comment = String(commentEl?.value || '').trim();
const shouldZap = Boolean(enableZapEl?.checked);
return { amountSats, comment, shouldZap };
};
const updateBalanceWarning = () => {
if (!warningEl) return;
const { amountSats } = parseCurrentValues();
const balance = Number(balanceSats);
const insufficient = Number.isFinite(balance)
&& balance >= 0
&& Number.isFinite(amountSats)
&& amountSats > balance;
warningEl.textContent = insufficient ? 'Insufficient balance' : '';
warningEl.style.display = insufficient ? '' : 'none';
};
const selectPreset = (button) => {
presetButtons.forEach((btn) => btn.classList.toggle('active', btn === button));
if (amountEl && button?.dataset?.amount) {
amountEl.value = button.dataset.amount;
}
updateBalanceWarning();
};
presetButtons.forEach((button) => {
button.addEventListener('click', () => selectPreset(button));
});
amountEl?.addEventListener('input', () => {
presetButtons.forEach((btn) => btn.classList.remove('active'));
updateBalanceWarning();
});
saveDefaultBtn?.addEventListener('click', async () => {
const { amountSats, comment } = parseCurrentValues();
if (!Number.isFinite(amountSats) || amountSats <= 0) {
amountEl?.focus();
return;
}
if (typeof onSaveDefault === 'function') {
try {
await onSaveDefault({ amountSats, comment });
saveDefaultBtn.textContent = 'Saved';
setTimeout(() => {
if (saveDefaultBtn) saveDefaultBtn.textContent = 'Save as default';
}, 1200);
} catch (error) {
console.error('[zaps] Save as default failed:', error?.message || error, error);
saveDefaultBtn.textContent = 'Save failed';
setTimeout(() => {
if (saveDefaultBtn) saveDefaultBtn.textContent = 'Save as default';
}, 1200);
}
return;
}
console.warn('[zaps] Save as default unavailable: onSaveDefault callback not provided');
saveDefaultBtn.textContent = 'Unavailable';
setTimeout(() => {
if (saveDefaultBtn) saveDefaultBtn.textContent = 'Save as default';
}, 1200);
});
sendBtn?.addEventListener('click', () => {
const { amountSats, comment, shouldZap } = parseCurrentValues();
if (shouldZap && (!Number.isFinite(amountSats) || amountSats <= 0)) {
amountEl?.focus();
return;
}
closeActiveZapModal({ amountSats, comment, shouldZap }, resolve);
});
cancelBtn?.addEventListener('click', () => closeActiveZapModal(null, resolve));
@@ -519,8 +658,6 @@ export function promptZapDetails(options = {}) {
} else {
presetButtons.forEach((btn) => btn.classList.remove('active'));
}
// Initialize rail selection and dependent UI state.
updateRailSelection(selectedRail);
updateBalanceWarning();
amountEl?.focus();
});

View File

@@ -110,6 +110,8 @@
}
.topPostComposerInput {
width: 100%;
box-sizing: border-box;
min-height: 6em;
padding: 8px;
line-height: 1.4;
@@ -177,7 +179,7 @@
<div id="divBody">
<div id="divBackBar">
<a id="btnBack" href="./feed2.html" title="Back to feed">← Feed</a>
<a id="btnBack" href="./feed.html" title="Back to feed">← Feed</a>
</div>
<div id="divHint">Loading thread…</div>
<div id="divThread">
@@ -896,6 +898,7 @@
replyComposerInstance = mountComposer(hostEl, {
currentPubkey,
followedProfiles: [],
layout: 'inline',
showUploadIcon: true,
showPreview: true,
autoHideOnSubmit: false,