Compare commits

...

10 Commits

Author SHA1 Message Date
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
Laan Tungir
15ef106eb5 Fix feed2.html: add missing walletGetBalance and walletGetMints imports
feed2.html was missing walletGetBalance and walletGetMints in both the
init-ndk.mjs import and the initPostCards call. Without these, the zap
rail selector and capability badges couldn't function — handleZapClick
couldn't resolve zap capabilities or show the rail toggle.
2026-06-25 20:55:02 -04:00
Laan Tungir
a42edcf6d6 Feed upgrade Phases 3-5: zap capability indicators, rail selector, balance checks
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
2026-06-25 20:48:38 -04:00
Laan Tungir
0267233f87 Fix post-feed.html reply composer formatting — add missing .topPostComposerInput CSS rule
The reply composer was missing the CSS rule that gives it visible formatting
(border, padding, min-height, border-radius). Copied the rule from post.html.
2026-06-25 20:29:51 -04:00
Laan Tungir
6b45092bc8 Fix post-feed.html: recursive reply fetching for nested replies
Replies to replies don't carry the root post ID in their e tags, only their
immediate parent's ID. The old single-level #e filter missed nested replies.

Added fetchRepliesRecursive() which does breadth-first recursive fetching:
- Starts with root post ID, fetches direct replies
- For each reply found, fetches replies to THAT reply
- Continues up to 5 levels deep (REPLY_FETCH_MAX_DEPTH)
- Deduplicates by event ID
- Per-level timeout (8s) to guard against slow relays
- Real-time handler also triggers 1-level recursive fetch for new replies

This matches Amethyst's ThreadFilterAssemblerSubscription approach.
2026-06-25 20:20:10 -04:00
Laan Tungir
6d39188967 Fix post-feed.html: remove collapse, fix missing reply, move composer to top
1. Removed collapse/expand functionality — all replies shown, no hidden counts
2. Fixed missing last reply — added safety check to ensure all fetched replies are rendered
3. Moved reply composer to top (under main post, before replies list)
2026-06-25 20:00:43 -04:00
Laan Tungir
c5f682c5fe Feed upgrade Phase 2.5: Threaded replies with vertical lines in post-feed.html
- Added computeReplyLevels() to compute reply depth from NIP-10 e tags
- Added buildDepthFirstOrder() for depth-first thread sorting (root first, children grouped by parent chronologically)
- Render vertical indent lines using CSS variables (var(--muted-color) for non-selected, var(--accent-color) for focused post)
- Added collapse/expand functionality with hidden reply count
- Added focused post highlight via ?focus=<eventId> URL param
- Real-time subscriptions preserved — new replies appear in correct threaded position
- All colors use CSS variables — no hardcoded colors
2026-06-25 19:53:36 -04:00
10 changed files with 1932 additions and 206 deletions

View File

@@ -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=<eventId>` 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=<eventId>` 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=<eventId>` in a new tab
- 🔄 Quote button — opens quote composer (no navigation)
- Comment button — opens `post-feed.html?event=<eventId>` 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=<replyId>` 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=<replyId>` in a new tab for that reply's sub-thread.
### Files to create
@@ -116,6 +117,97 @@ Accepts the same event identifier formats as the rest of the project:
---
## Phase 2.5: Threaded Replies with Vertical Lines (post-feed.html)
Upgrade the flat reply list in `post-feed.html` to show **threaded replies with vertical indent lines**, matching Amethyst's thread view.
### How Amethyst does it
Amethyst uses two key components:
1. **`ThreadLevelCalculator.replyLevel(note, cachedLevels)`** — computes the depth of each reply recursively:
- Root post (no `e` tag reply) → level 0
- A reply's level = `max(parent's level for each parent) + 1`
- Parents are determined by NIP-10 `e` tags in the event
2. **`Modifier.drawReplyLevel(level, color, selected)`** — draws vertical lines to the left of each post:
- Draws `level` vertical lines, each 2px wide, spaced 3px apart
- The last line (closest to the post) uses the "selected" color
- All other lines use the "muted" color
- The post content is padded left by `2 + (level * 3)px`
### Visual design
```
Original post (level 0)
│ Reply A (level 1) — direct reply to original
│ │ Reply A1 (level 2) — reply to Reply A
│ │ Reply A2 (level 2) — another reply to Reply A
│ Reply B (level 1) — another direct reply to original
│ │ Reply B1 (level 2) — reply to Reply B
│ │ │ Reply B1a (level 3) — reply to Reply B1
│ Reply C (level 1) — yet another direct reply
```
Each `│` is a vertical line drawn with CSS `border-left` on a container div. The lines use:
- `var(--muted-color)` for non-selected levels
- `var(--accent-color)` for the current post's level (the post being viewed)
### Tasks
- [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<eventId, level>`
- [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
- [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)`
- [x] ~~**2.5.4** Add collapse/expand functionality~~**Removed per user request.** All replies are shown, no collapse.
- [x] **2.5.5** Highlight the focused post:
- If the URL has a `focus=<eventId>` 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)
```css
.reply-thread-line {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background: var(--muted-color);
}
.reply-thread-line.selected {
background: var(--accent-color);
}
.reply-item {
position: relative;
padding-left: 12px; /* per level */
}
```
### Files to modify
| File | Changes |
|------|---------|
| [`www/post-feed.html`](www/post-feed.html) | Add level computation, depth-first sorting, vertical line rendering, collapse/expand |
---
## Phase 3: Zap Capability Indicators in the Feed
Show zap capability badges on each post's interaction bar so users can see at a glance whether the recipient can receive nutzaps, Lightning zaps, or both.

View File

@@ -1388,6 +1388,136 @@ 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;
}
/* 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>
@@ -279,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 }) {
@@ -325,7 +335,6 @@ import { initPostCards } from './js/post-interactions2.mjs';
publishEvent,
getPubkey,
ndkFetchEvents,
queryCache,
fetchCachedProfile,
storeProfile,
walletPayInvoice,
@@ -630,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();
@@ -705,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,8 +9,10 @@ import { createProfileCache } from './profile-cache.mjs';
import { mountDotMenu } from './dot-menu.mjs';
import {
promptZapDetails,
promptNutzapDetails,
prepareZapInvoiceForEvent,
resolveNutzapSpecForPubkey,
resolveZapCapabilities,
extractZapAmount as extractZapAmountFromReceipt,
extractZapComment as extractZapCommentFromReceipt
} from './zaps.mjs';
@@ -879,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
*/
@@ -957,6 +960,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;
@@ -1049,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,
@@ -1059,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';
@@ -1069,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
@@ -1079,7 +1092,11 @@ export function renderFooterRow(eventId, eventData, options = {}) {
container.appendChild(timeEl);
footerRow.appendChild(container);
// 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;
}
@@ -1126,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,
@@ -1137,14 +1154,27 @@ 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 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;
}
@@ -1194,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);
}
}
@@ -1272,34 +1302,80 @@ 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 DIMMING
// =============================================================================
//
// 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
//
// Dimming is applied asynchronously after the bar is in the DOM so feed
// rendering is never blocked. Colors come exclusively from CSS variables.
/**
* 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 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 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,
fetchMintList: walletFetchMintListFn || undefined,
getSenderMints: walletGetMintsFn || undefined,
logPrefix: '[post-interactions][zap-caps]'
}).then((caps) => {
// 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';
}
}
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] dimming failed', error?.message || error);
});
}
// =============================================================================
@@ -1323,6 +1399,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<number|null>}
*/
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<void>}
*/
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
// =============================================================================
@@ -1558,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;
@@ -1583,34 +1731,31 @@ 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)');
}
let nutzapSpec = null;
if (typeof walletFetchMintListFn === 'function') {
// --- Fetch the user's Cashu balance for the dialog ----------------------
let balanceSats = null;
if (typeof walletGetBalanceFn === 'function') {
try {
nutzapSpec = await resolveNutzapSpecForPubkey(postPubkey, {
fetchMintList: walletFetchMintListFn,
logPrefix: '[post-interactions][nutzap]'
});
} catch (_nutzapDiscoveryError) {
nutzapSpec = null;
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);
}
}
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
onSaveDefault: saveZapDefaultsToSettings,
balanceSats
});
if (!details) {
console.log('[post-interactions][zap] handleZapClick:cancelled-by-user');
@@ -1652,40 +1797,59 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
return;
}
if (nutzapSpec?.mints?.length > 0 && typeof walletSendNutzapFn === 'function') {
if (countEl) countEl.textContent = 'send…';
console.log('[post-interactions][zap] handleZapClick: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][zap] handleZapClick:nutzap-ok', nutzapResult || {});
} else {
if (typeof walletPayInvoiceFn !== 'function') {
throw new Error('LN zap fallback unavailable (walletPayInvoice missing)');
// --- 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;
}
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 || {});
// "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;
}
}
}
// --- 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 (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');
itemEl.classList.add('zap-success');
@@ -1702,15 +1866,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);
@@ -1728,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
@@ -2308,6 +2734,8 @@ export function initInteractions(ndkFunctions = {}) {
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
walletGetMints,
walletGetBalance,
getRelayData,
getUserSettings,
patchUserSettings,
@@ -2325,6 +2753,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;

View File

@@ -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,
@@ -65,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.25",
"VERSION_NUMBER": "0.7.25",
"BUILD_DATE": "2026-06-25T23:45:36.529Z"
"VERSION": "v0.7.35",
"VERSION_NUMBER": "0.7.35",
"BUILD_DATE": "2026-06-26T01:18:47.392Z"
}

View File

@@ -249,6 +249,37 @@ 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]);
}
/**
* 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>}
*/
export function promptZapDetails(options = {}) {
if (activeZapModalEl) {
return Promise.resolve(null);
@@ -258,7 +289,8 @@ export function promptZapDetails(options = {}) {
defaultAmountSats = 21,
defaultComment = '',
defaultShouldZap = true,
onSaveDefault = null
onSaveDefault = null,
balanceSats = null
} = options;
return new Promise((resolve) => {
@@ -268,11 +300,16 @@ export function promptZapDetails(options = {}) {
: 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 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>
<div class="zap-selector" role="dialog" aria-modal="true" aria-label="Send Lightning zap">
<div class="zap-selector-title">⚡ Lightning Zap</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>
@@ -284,9 +321,10 @@ export function promptZapDetails(options = {}) {
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>
<label class="zap-selector-label">
Comment (optional)
<textarea class="zap-selector-comment" rows="3" placeholder="Nice post ⚡">${normalizedDefaultComment.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</textarea>
<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' : ''} />
@@ -307,6 +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 warningEl = overlay.querySelector('[data-warning="insufficient-balance"]');
const parseCurrentValues = () => {
const amountSats = Number(amountEl?.value || 0);
@@ -315,11 +354,24 @@ export function promptZapDetails(options = {}) {
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) => {
@@ -328,6 +380,7 @@ export function promptZapDetails(options = {}) {
amountEl?.addEventListener('input', () => {
presetButtons.forEach((btn) => btn.classList.remove('active'));
updateBalanceWarning();
});
saveDefaultBtn?.addEventListener('click', async () => {
@@ -390,6 +443,222 @@ export function promptZapDetails(options = {}) {
} 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));
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();
});
}
@@ -506,3 +775,170 @@ export function extractZapComment(event) {
}
return '';
}
// =============================================================================
// ZAP CAPABILITY RESOLUTION (Phase 3 — feed-upgrade)
// =============================================================================
//
// resolveZapCapabilities(pubkey) determines, at a glance, which zap rails are
// available for a given recipient pubkey:
// - canLightning: recipient has a lud16 Lightning address (NIP-57 via Cashu melt)
// - canNutzap: recipient has a kind 10019 mint list (NIP-61) AND at least
// one of those mints overlaps with the sender's wallet mints
// - sharedMints: the overlapping mint URLs (empty when no overlap)
// - nutzapMints: all mints declared on the recipient's kind 10019
// - lud16: the recipient's Lightning address (or null)
//
// Results are cached per-pubkey in an in-memory Map with a 5-minute TTL so
// repeated feed renders do not refetch. The cache is best-effort and safe to
// clear on page navigation (see clearZapCapabilitiesCache).
const ZAP_CAP_CACHE_TTL_MS = 5 * 60 * 1000;
const zapCapCache = new Map(); // pubkey -> { result, expiresAt }
/**
* Clear the in-memory zap-capability cache.
* Safe to call on page navigation; subsequent calls will refetch.
*/
export function clearZapCapabilitiesCache() {
zapCapCache.clear();
}
/**
* Normalize a mint URL for comparison (trim + strip trailing slashes).
* @param {string} url
* @returns {string}
*/
function normalizeMintForCompare(url) {
return String(url || '').trim().replace(/\/+$/, '').toLowerCase();
}
/**
* Resolve which zap rails are available for a given pubkey.
*
* @param {string} pubkey - The recipient's pubkey (hex)
* @param {Object} options
* @param {Function} [options.fetchProfile] - profile-cache fetcher (kind 0)
* @param {Function} [options.fetchMintList] - walletFetchMintList(pubkey) -> { hasMintList, mints, ... }
* @param {Function} [options.getSenderMints] - walletGetMints() -> { mints: [...] }
* @param {boolean} [options.useCache=true] - whether to consult the in-memory cache
* @param {string} [options.logPrefix='[zaps]']
* @returns {Promise<{canLightning:boolean, canNutzap:boolean, sharedMints:string[], nutzapMints:string[], lud16:string|null, hasMintList:boolean}>}
*/
export async function resolveZapCapabilities(pubkey, options = {}) {
const {
fetchProfile: fetchProfileFn,
fetchMintList,
getSenderMints,
useCache = true,
logPrefix = '[zaps]'
} = options;
const recipient = String(pubkey || '').trim();
if (!recipient) {
return {
canLightning: false,
canNutzap: false,
sharedMints: [],
nutzapMints: [],
lud16: null,
hasMintList: false
};
}
// --- Cache lookup ---------------------------------------------------------
const now = Date.now();
if (useCache) {
const cached = zapCapCache.get(recipient);
if (cached && cached.expiresAt > now) {
return cached.result;
}
}
// --- Lightning capability (lud16 from kind 0) -----------------------------
let lud16 = null;
let canLightning = false;
if (typeof fetchProfileFn === 'function') {
try {
const profile = await fetchProfileFn(recipient);
const normalized = normalizeLud16(profile?.lud16);
if (normalized) {
lud16 = normalized;
canLightning = true;
}
} catch (error) {
console.warn(`${logPrefix} resolveZapCapabilities: profile fetch failed`, error?.message || error);
}
}
// --- Nutzap capability (kind 10019 + shared mint overlap) -----------------
let nutzapMints = [];
let sharedMints = [];
let canNutzap = false;
let hasMintList = false;
if (typeof fetchMintList === 'function') {
try {
const mintList = await fetchMintList(recipient);
const rawMints = Array.isArray(mintList?.mints)
? mintList.mints.map((m) => String(m || '').trim()).filter(Boolean)
: [];
nutzapMints = Array.from(new Set(rawMints));
hasMintList = Boolean(mintList?.hasMintList) && nutzapMints.length > 0;
if (hasMintList) {
// Determine the sender's wallet mints to compute overlap.
let senderMints = [];
if (typeof getSenderMints === 'function') {
try {
const senderResult = await getSenderMints();
senderMints = Array.isArray(senderResult?.mints)
? senderResult.mints.map((m) => String(m || '').trim()).filter(Boolean)
: [];
} catch (error) {
console.warn(`${logPrefix} resolveZapCapabilities: sender mints fetch failed`, error?.message || error);
}
}
if (senderMints.length > 0) {
const senderSet = new Set(senderMints.map(normalizeMintForCompare));
sharedMints = nutzapMints.filter((m) => senderSet.has(normalizeMintForCompare(m)));
canNutzap = sharedMints.length > 0;
} else {
// No sender wallet mints available yet — cannot confirm a shared mint,
// so we do not claim canNutzap. The recipient still has a mint list,
// which the UI may render in a muted state.
canNutzap = false;
}
}
} catch (error) {
console.warn(`${logPrefix} resolveZapCapabilities: mint list fetch failed`, error?.message || error);
}
}
const result = {
canLightning,
canNutzap,
sharedMints,
nutzapMints,
lud16,
hasMintList
};
// --- Cache store ----------------------------------------------------------
zapCapCache.set(recipient, {
result,
expiresAt: Date.now() + ZAP_CAP_CACHE_TTL_MS
});
console.log(`${logPrefix} resolveZapCapabilities:ok`, {
recipient: recipient.slice(0, 8) + '…',
canLightning,
canNutzap,
hasMintList,
sharedMints: sharedMints.length,
nutzapMints: nutzapMints.length
});
return result;
}

View File

@@ -294,6 +294,15 @@ let lightwalletHasWallet = false;
let spendingHistoryLastFetchAtMs = 0;
let spendingHistoryFetchPromise = null;
// Nutzap mint list (kind 10019) cache for followed users — proactively
// fetched on startup so resolveZapCapabilities() can hit IDB instead of
// going to relays for every followed author.
const NUTZAP_MINTLIST_DB_NAME = 'ndk-nutzap-mintlists';
const NUTZAP_MINTLIST_DB_VERSION = 1;
const NUTZAP_MINTLIST_STORE = 'mintlists'; // keyPath: pubkey
const NUTZAP_MINTLIST_PREFETCH_BATCH = 50; // max authors per fetchEvents call
const NUTZAP_MINTLIST_PREFETCH_TTL_MS = 6 * 60 * 60 * 1000; // 6h freshness guard
// Relay activity tracking
const RELAY_EVENT_LOG_LIMIT = 200;
const RELAY_EVENTS_DB_NAME = 'relay-events';
@@ -600,6 +609,89 @@ async function openRelayEventsDb() {
});
}
// -----------------------------------------------------------------------------
// Nutzap mint list (kind 10019) IDB cache for followed users
// -----------------------------------------------------------------------------
async function openNutzapMintListDb() {
if (!HAS_INDEXEDDB || !INDEXEDDB_USABLE) {
throw new Error('IndexedDB unavailable or unhealthy');
}
return await new Promise((resolve, reject) => {
const req = indexedDB.open(NUTZAP_MINTLIST_DB_NAME, NUTZAP_MINTLIST_DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(NUTZAP_MINTLIST_STORE)) {
db.createObjectStore(NUTZAP_MINTLIST_STORE, { keyPath: 'pubkey' });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
/**
* Store a parsed kind 10019 mint list for a pubkey in IDB.
* @param {string} pubkey
* @param {{ hasMintList:boolean, mints:string[], relays:string[], p2pk:string|null, eventId:string|null, created_at:number }} entry
*/
async function putNutzapMintListToDb(pubkey, entry) {
if (!pubkey) return;
try {
const db = await openNutzapMintListDb();
if (!db.objectStoreNames.contains(NUTZAP_MINTLIST_STORE)) {
db.close();
return;
}
await new Promise((resolve, reject) => {
const tx = db.transaction(NUTZAP_MINTLIST_STORE, 'readwrite');
tx.objectStore(NUTZAP_MINTLIST_STORE).put({
pubkey,
...entry,
cachedAt: Date.now()
});
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
} catch (error) {
console.warn('[Worker] putNutzapMintListToDb failed', error?.message || error);
}
}
/**
* Read a cached kind 10019 mint list for a pubkey from IDB.
* Returns null when missing or stale (older than NUTZAP_MINTLIST_PREFETCH_TTL_MS).
* @param {string} pubkey
* @returns {Promise<Object|null>}
*/
async function getNutzapMintListFromDb(pubkey) {
if (!pubkey) return null;
try {
const db = await openNutzapMintListDb();
if (!db.objectStoreNames.contains(NUTZAP_MINTLIST_STORE)) {
db.close();
return null;
}
const row = await new Promise((resolve) => {
const tx = db.transaction(NUTZAP_MINTLIST_STORE, 'readonly');
const req = tx.objectStore(NUTZAP_MINTLIST_STORE).get(pubkey);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => resolve(null);
});
db.close();
if (!row) return null;
if (Date.now() - Number(row.cachedAt || 0) > NUTZAP_MINTLIST_PREFETCH_TTL_MS) {
return null; // stale
}
return row;
} catch (error) {
console.warn('[Worker] getNutzapMintListFromDb failed', error?.message || error);
return null;
}
}
function serializeRelayEventData(data) {
if (data === null || data === undefined) return null;
if (typeof data === 'string' || typeof data === 'number' || typeof data === 'boolean') return data;
@@ -1867,6 +1959,22 @@ async function fetchStartupEventDownloads(pubkey) {
await processIncomingNutzaps(eventsByKey.kind9321 || [], { context: 'startup-fetch' });
broadcastStartupStatus('Startup downloads complete', { stage: 'startup-downloads', state: 'done', counts });
// Tier 1: proactively prefetch kind 10019 nutzap mint lists for all
// followed users so resolveZapCapabilities() can hit IDB. This runs in the
// background and must not block startup completion, so we do not await it.
try {
const kind3Events = Array.from(eventsByKey.kind3 || []);
const latestKind3 = kind3Events
.sort((a, b) => Number(b?.created_at || 0) - Number(a?.created_at || 0))[0] || null;
if (latestKind3) {
prefetchFollowedNutzapMintLists(latestKind3).catch((error) => {
console.warn('[Worker] background kind 10019 prefetch failed', error?.message || error);
});
}
} catch (prefetchError) {
console.warn('[Worker] kind 10019 prefetch scheduling failed', prefetchError?.message || prefetchError);
}
}
function ensureStartupWalletSubscriptions(pubkey) {
@@ -3205,6 +3313,142 @@ async function handleWalletPublishMintList(requestId, relays, receiveMints, port
}
}
/**
* Parse a kind 3 (contact list) event into a de-duplicated array of followed
* pubkeys (the 'p' tags).
* @param {Object|null|undefined} kind3Event
* @returns {string[]}
*/
function extractFollowedPubkeysFromKind3(kind3Event) {
if (!kind3Event || !Array.isArray(kind3Event.tags)) return [];
const pubkeys = new Set();
for (const tag of kind3Event.tags) {
if (Array.isArray(tag) && tag[0] === 'p' && tag[1] && String(tag[1]).length >= 32) {
pubkeys.add(String(tag[1]));
}
}
return Array.from(pubkeys);
}
/**
* Parse a raw kind 10019 event into the mint-list shape used by the cache.
* @param {Object} event
* @returns {{ hasMintList:boolean, mints:string[], relays:string[], p2pk:string|null, eventId:string|null, created_at:number }}
*/
function parseKind10019Event(event) {
const mints = Array.from(new Set((event.tags || [])
.filter((tag) => tag?.[0] === 'mint' && tag?.[1])
.map((tag) => normalizeMintUrl(tag[1]))
.filter(Boolean)));
const relays = Array.from(new Set((event.tags || [])
.filter((tag) => tag?.[0] === 'relay' && tag?.[1])
.map((tag) => String(tag[1]).trim())
.filter((url) => /^wss?:\/\//i.test(url))));
const p2pkTag = (event.tags || []).find((tag) => tag?.[0] === 'pubkey' && tag?.[1]);
const p2pk = normalizeCashuP2pk(p2pkTag?.[1] || '') || null;
return {
hasMintList: mints.length > 0,
mints,
relays,
p2pk,
eventId: event.id || null,
created_at: Number(event.created_at || 0)
};
}
/**
* Proactively fetch kind 10019 (nutzap mint lists) for all followed pubkeys
* discovered in the user's kind 3 contact list, and cache the results in
* IndexedDB. This is the Tier 1 fetch strategy: followed users are pre-warmed
* on startup so resolveZapCapabilities() can hit IDB instead of relays.
*
* Runs in the background and never rejects — failures are logged only.
*
* @param {Object|null|undefined} kind3Event - the user's kind 3 contact list
*/
async function prefetchFollowedNutzapMintLists(kind3Event) {
if (!ndk) return;
const pubkeys = extractFollowedPubkeysFromKind3(kind3Event);
if (pubkeys.length === 0) {
broadcastStartupStatus('No followed pubkeys to prefetch nutzap mint lists for', {
stage: 'kind10019-prefetch',
state: 'skipped'
});
return;
}
broadcastStartupStatus(`Prefetching nutzap mint lists (kind 10019) for ${pubkeys.length} followed users...`, {
stage: 'kind10019-prefetch',
state: 'start',
count: pubkeys.length
});
const startedAt = Date.now();
let stored = 0;
try {
// Fetch in batches to keep relay filters reasonable.
for (let i = 0; i < pubkeys.length; i += NUTZAP_MINTLIST_PREFETCH_BATCH) {
const batch = pubkeys.slice(i, i + NUTZAP_MINTLIST_PREFETCH_BATCH);
try {
const events = await ndk.fetchEvents({
kinds: [10019],
authors: batch
});
const eventList = Array.from(events || []);
// Group events by author and keep only the latest per pubkey.
const latestByAuthor = new Map();
for (const event of eventList) {
const prev = latestByAuthor.get(event.pubkey);
if (!prev || Number(event.created_at || 0) > Number(prev.created_at || 0)) {
latestByAuthor.set(event.pubkey, event);
}
}
for (const [author, event] of latestByAuthor) {
const parsed = parseKind10019Event(event);
await putNutzapMintListToDb(author, parsed);
stored++;
}
// Record an explicit "no mint list" entry for followed pubkeys
// that returned nothing, so we can short-circuit future lookups
// within the TTL window.
for (const author of batch) {
if (!latestByAuthor.has(author)) {
await putNutzapMintListToDb(author, {
hasMintList: false,
mints: [],
relays: [],
p2pk: null,
eventId: null,
created_at: 0
});
}
}
} catch (batchError) {
console.warn('[Worker] kind 10019 prefetch batch failed', batchError?.message || batchError);
}
}
broadcastStartupStatus(`Nutzap mint list prefetch done (${stored} stored)`, {
stage: 'kind10019-prefetch',
state: 'done',
count: stored,
durationMs: Date.now() - startedAt
});
} catch (error) {
broadcastStartupStatus('Nutzap mint list prefetch failed', {
stage: 'kind10019-prefetch',
state: 'failed',
error: error?.message || String(error),
durationMs: Date.now() - startedAt
});
console.warn('[Worker] prefetchFollowedNutzapMintLists failed', error?.message || error);
}
}
async function handleWalletFetchMintList(requestId, targetPubkey, port) {
try {
if (!ndk) throw new Error('NDK not initialized');
@@ -3214,6 +3458,30 @@ async function handleWalletFetchMintList(requestId, targetPubkey, port) {
throw new Error('Target pubkey is required');
}
// Tier 2 lazy fetch: consult the IDB cache (populated by the Tier 1
// startup prefetch) before hitting relays. A fresh cache hit avoids a
// round-trip for non-followed-but-recently-seen pubkeys too.
try {
const cached = await getNutzapMintListFromDb(author);
if (cached) {
port.postMessage({
type: 'response',
requestId,
data: {
hasMintList: Boolean(cached.hasMintList),
mints: Array.isArray(cached.mints) ? cached.mints : [],
relays: Array.isArray(cached.relays) ? cached.relays : [],
p2pk: cached.p2pk || null,
eventId: cached.eventId || null,
created_at: Number(cached.created_at || 0)
}
});
return;
}
} catch (_cacheError) {
// Fall through to relay fetch on any cache error.
}
const events = await ndk.fetchEvents({
kinds: [10019],
authors: [author],
@@ -3224,43 +3492,35 @@ async function handleWalletFetchMintList(requestId, targetPubkey, port) {
.sort((a, b) => Number(b?.created_at || 0) - Number(a?.created_at || 0))[0];
if (!latest) {
port.postMessage({
type: 'response',
requestId,
data: {
hasMintList: false,
mints: [],
relays: [],
p2pk: null,
eventId: null
}
});
const emptyEntry = {
hasMintList: false,
mints: [],
relays: [],
p2pk: null,
eventId: null,
created_at: 0
};
// Cache the negative result so repeated lookups are cheap.
putNutzapMintListToDb(author, emptyEntry).catch(() => {});
port.postMessage({ type: 'response', requestId, data: emptyEntry });
return;
}
const mints = Array.from(new Set((latest.tags || [])
.filter((tag) => tag?.[0] === 'mint' && tag?.[1])
.map((tag) => normalizeMintUrl(tag[1]))
.filter(Boolean)));
const parsed = parseKind10019Event(latest);
const relays = Array.from(new Set((latest.tags || [])
.filter((tag) => tag?.[0] === 'relay' && tag?.[1])
.map((tag) => String(tag[1]).trim())
.filter((url) => /^wss?:\/\//i.test(url))));
const p2pkTag = (latest.tags || []).find((tag) => tag?.[0] === 'pubkey' && tag?.[1]);
const p2pk = normalizeCashuP2pk(p2pkTag?.[1] || '') || null;
// Cache the relay result for future lookups.
putNutzapMintListToDb(author, parsed).catch(() => {});
port.postMessage({
type: 'response',
requestId,
data: {
hasMintList: mints.length > 0,
mints,
relays,
p2pk,
eventId: latest.id || null,
created_at: Number(latest.created_at || 0)
hasMintList: parsed.hasMintList,
mints: parsed.mints,
relays: parsed.relays,
p2pk: parsed.p2pk,
eventId: parsed.eventId,
created_at: parsed.created_at
}
});
} catch (error) {

View File

@@ -109,6 +109,14 @@
width: 100%;
}
.topPostComposerInput {
min-height: 6em;
padding: 8px;
line-height: 1.4;
border: 2px solid var(--primary-color);
border-radius: 10px;
}
.divPostItem .post-composer-wrapper {
width: 100%;
}
@@ -125,6 +133,34 @@
margin-bottom: 12px;
color: var(--primary-color);
}
/* ============================================================
THREADED REPLIES — vertical indent lines (Phase 2.5)
All colors via CSS variables — never hardcoded.
============================================================ */
.reply-thread-container {
position: relative;
padding-left: 12px; /* per-level indent is applied inline */
}
.reply-thread-line {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: var(--muted-color);
pointer-events: none;
}
.reply-thread-line.selected {
background: var(--accent-color);
}
.reply-item-focused {
outline: 2px solid var(--accent-color);
outline-offset: 2px;
border-radius: 10px;
}
</style>
</head>
@@ -141,15 +177,15 @@
<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">
<div id="divOriginalPost"></div>
<div id="divComposer"></div>
<div id="divThreadHeader">Replies</div>
<div id="divReplies"></div>
</div>
<div id="divComposer"></div>
</div>
<div id="divFooter">
@@ -216,6 +252,8 @@
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
walletGetMints,
walletGetBalance,
getRelayData,
getUserSettings,
patchUserSettings,
@@ -290,6 +328,7 @@
const replies = []; // flat list of reply events
const replyIds = new Set(); // dedupe by event id
const renderedReplyIds = new Set();
const focusedEventId = (urlParams.get('focus') || '').trim().toLowerCase() || null;
let hamburgerInstance = null;
let logoutHamburger = null;
@@ -457,6 +496,8 @@
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
walletGetMints,
walletGetBalance,
getRelayData,
getUserSettings,
patchUserSettings,
@@ -539,14 +580,18 @@
}
/* ================================================================
REPLY THREAD RENDERING (flat, chronological ascending)
REPLY THREAD RENDERING — threaded with vertical indent lines
(Phase 2.5 — matches Amethyst's thread view)
================================================================ */
function isReplyToThread(evt) {
if (!evt?.tags) return false;
const eTags = evt.tags.filter((t) => t[0] === 'e' && t[1]);
if (eTags.length === 0) return false;
// A reply is any kind 1 that references the target event id in an e tag.
return eTags.some((t) => t[1] === targetEventId);
// A reply is any kind 1 that references the root post OR any already-known
// reply in the thread (nested replies only carry their immediate parent,
// not the root post id). This keeps the thread inclusive of replies-to-replies.
if (eTags.some((t) => t[1] === targetEventId)) return true;
return eTags.some((t) => replyIds.has(t[1]));
}
function upsertReply(evt) {
@@ -559,10 +604,197 @@
return true;
}
function renderReplies() {
replies.sort((a, b) => (a.created_at || 0) - (b.created_at || 0));
/* ----------------------------------------------------------------
NIP-10 parent extraction
----------------------------------------------------------------
Returns the direct parent event id for a reply event, per NIP-10:
- The last `e` tag with marker "reply" is the direct parent.
- If no marked "reply" tag, fall back to the last unmarked `e`
tag (legacy NIP-10).
- `e` tags with marker "root" or "mention" are NOT the parent.
Returns null if no parent can be determined.
---------------------------------------------------------------- */
function getDirectParentId(evt) {
if (!evt?.tags) return null;
const eTags = evt.tags.filter((t) => t[0] === 'e' && t[1]);
if (eTags.length === 0) return null;
// Re-render the whole reply list (flat thread, simplest & most reliable).
// Prefer the last e tag explicitly marked "reply".
let lastReply = null;
let lastUnmarked = null;
for (const t of eTags) {
const marker = t[3];
if (marker === 'reply') {
lastReply = t[1];
} else if (marker === 'root' || marker === 'mention') {
// not a direct parent
} else {
// unmarked — legacy NIP-10, last one is the parent
lastUnmarked = t[1];
}
}
if (lastReply) return lastReply;
if (lastUnmarked) return lastUnmarked;
// All e tags are root/mention markers — no direct parent identifiable.
return null;
}
/* ----------------------------------------------------------------
computeReplyLevels(events, rootEventId)
----------------------------------------------------------------
Builds eventId -> parentEventId map from NIP-10 e tags, then
computes a level (depth) for each event by walking up the parent
chain to the root. Returns a Map<eventId, level>.
- Root post (id === rootEventId, or no parent) -> level 0
- Direct reply to root -> level 1
- Reply to a level-N reply -> level N+1
- Missing parent (not in the fetched set) -> treated as level 1
- Cycle protection: cap at a sane depth (256) to avoid infinite
loops on malformed events.
---------------------------------------------------------------- */
function computeReplyLevels(events, rootEventId) {
const byId = new Map();
for (const evt of events) {
if (evt?.id) byId.set(evt.id, evt);
}
if (rootEventId && originalPost && !byId.has(rootEventId)) {
byId.set(rootEventId, originalPost);
}
const parentOf = new Map(); // eventId -> parentEventId | null
for (const evt of events) {
if (!evt?.id) continue;
if (evt.id === rootEventId) {
parentOf.set(evt.id, null);
continue;
}
const pid = getDirectParentId(evt);
parentOf.set(evt.id, pid || null);
}
const levels = new Map();
const MAX_DEPTH = 256;
function resolveLevel(eventId, seen) {
if (levels.has(eventId)) return levels.get(eventId);
if (eventId === rootEventId) {
levels.set(eventId, 0);
return 0;
}
if (seen.has(eventId)) {
// cycle — treat as level 1 to break the loop
levels.set(eventId, 1);
return 1;
}
seen.add(eventId);
const pid = parentOf.get(eventId);
if (!pid) {
// No parent identifiable — treat as a direct reply to root.
levels.set(eventId, 1);
return 1;
}
if (!byId.has(pid)) {
// Parent not in the fetched set — treat as direct reply (level 1).
levels.set(eventId, 1);
return 1;
}
const parentLevel = resolveLevel(pid, seen);
const lvl = Math.min(parentLevel + 1, MAX_DEPTH);
levels.set(eventId, lvl);
return lvl;
}
for (const evt of events) {
if (!evt?.id) continue;
if (!levels.has(evt.id)) {
resolveLevel(evt.id, new Set());
}
}
return levels;
}
/* ----------------------------------------------------------------
Depth-first ordering
----------------------------------------------------------------
Builds a parent -> children map and traverses depth-first,
children sorted chronologically (oldest first). The root's
children come first, then each child's subtree before moving
to the next sibling. This makes vertical lines visually
contiguous — a reply's descendants appear directly below it.
Returns an array of { event, level } in display order.
---------------------------------------------------------------- */
function buildDepthFirstOrder(events, levels, rootEventId) {
const childrenOf = new Map(); // parentId -> [event, ...]
for (const evt of events) {
if (!evt?.id || evt.id === rootEventId) continue;
const pid = getDirectParentId(evt);
let parentKey;
if (pid && events.some((e) => e?.id === pid)) {
parentKey = pid;
} else {
// Parent not in set — group under root.
parentKey = rootEventId;
}
if (!childrenOf.has(parentKey)) childrenOf.set(parentKey, []);
childrenOf.get(parentKey).push(evt);
}
// Sort each child group chronologically (oldest first).
for (const arr of childrenOf.values()) {
arr.sort((a, b) => (a.created_at || 0) - (b.created_at || 0));
}
const ordered = [];
const visited = new Set();
function visit(parentId, level) {
const kids = childrenOf.get(parentId) || [];
for (const child of kids) {
if (visited.has(child.id)) continue;
visited.add(child.id);
const childLevel = levels.get(child.id) ?? level + 1;
ordered.push({ event: child, level: childLevel });
visit(child.id, childLevel);
}
}
visit(rootEventId, 0);
// Debug check: make sure every event in the input set appears in
// the ordered list. If any are missing (e.g. an orphaned reply
// whose parent is not in the fetched set but was somehow not
// grouped under root), log a warning and append them at the end
// so no reply is silently dropped.
const orderedIds = new Set(ordered.map((o) => o.event?.id));
const missing = [];
for (const evt of events) {
if (!evt?.id || evt.id === rootEventId) continue;
if (!orderedIds.has(evt.id)) {
missing.push(evt);
}
}
if (missing.length > 0) {
console.warn(
`[post-feed.html] ${missing.length} reply event(s) missing from depth-first order — appending at end:`,
missing.map((e) => e.id)
);
for (const evt of missing) {
ordered.push({ event: evt, level: levels.get(evt.id) ?? 1 });
}
}
return ordered;
}
/* ----------------------------------------------------------------
renderReplies() — threaded rendering with vertical indent lines
---------------------------------------------------------------- */
function renderReplies() {
divReplies.innerHTML = '';
renderedReplyIds.clear();
@@ -572,21 +804,65 @@
return;
}
divThreadHeader.textContent = `${replies.length} ${replies.length === 1 ? 'reply' : 'replies'}`;
const levels = computeReplyLevels(replies, targetEventId);
const ordered = buildDepthFirstOrder(replies, levels, targetEventId);
replies.forEach((reply) => {
const totalLabel = `${replies.length} ${replies.length === 1 ? 'reply' : 'replies'}`;
divThreadHeader.textContent = totalLabel;
const wiredIds = [];
ordered.forEach(({ event: reply, level }) => {
const container = document.createElement('div');
container.className = 'reply-thread-container';
container.dataset.replyId = reply.id;
container.dataset.level = String(level);
// Per-level left padding so the post content sits to the right of
// all the vertical lines for its ancestors.
container.style.paddingLeft = `${level * 12}px`;
// Vertical indent lines: one per ancestor level (0..level-1).
// The last line (level-1, closest to the post) uses the accent
// color if this is the focused post; all others use muted.
const isFocused = focusedEventId && reply.id === focusedEventId;
for (let i = 0; i < level; i += 1) {
const line = document.createElement('div');
line.className = 'reply-thread-line';
// Position each line at left = i * 12px (within the container's
// padding box). The container has paddingLeft = level*12, so the
// lines sit in the padded gutter.
line.style.left = `${i * 12}px`;
if (isFocused && i === level - 1) {
line.classList.add('selected');
}
container.appendChild(line);
}
// The post card itself.
const replyEl = cards.createCard(reply, { currentPubkey, autoWire: false, autoplayVideo: false });
divReplies.appendChild(replyEl);
replyEl.dataset.threadLevel = String(level);
if (isFocused) {
replyEl.classList.add('reply-item-focused');
}
container.appendChild(replyEl);
divReplies.appendChild(container);
renderedReplyIds.add(reply.id);
wiredIds.push(reply.id);
});
cards.wireInteractions(replies.map((r) => r.id), { currentPubkey });
cards.wireInteractions(wiredIds, { currentPubkey });
divFooterRight.textContent = `${1 + replies.length} posts`;
}
function appendReplyIfNew(evt) {
if (!upsertReply(evt)) return;
renderReplies();
// If a focus param was provided, scroll the focused post into view.
if (focusedEventId) {
const focusEl = divReplies.querySelector(`.reply-thread-container[data-reply-id="${focusedEventId}"]`);
if (focusEl) {
requestAnimationFrame(() => {
focusEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}
}
}
/* ================================================================
@@ -698,27 +974,104 @@
});
}
/* ----------------------------------------------------------------
fetchRepliesRecursive(rootEventId, maxDepth)
----------------------------------------------------------------
Recursively fetches replies to the root post AND replies to those
replies (nested replies), up to maxDepth levels. Nested replies
only carry their immediate parent in their `e` tags — not the root
post id — so a single { '#e': [rootId] } query misses them.
Strategy (breadth-first by depth level):
depth 0: fetch replies to rootEventId
depth 1: fetch replies to every reply found at depth 0
depth 2: fetch replies to every reply found at depth 1
... up to maxDepth
The `#e` filter accepts an array of ids, so we batch all ids at
the current depth into a single fetch. Dedup via replyIds so a
reply discovered via multiple parent paths is only added once.
Both the cache (queryCache) and relays (ndkFetchEvents) are
consulted at each level — cache first for fast display, then
relays for hydration. A per-level timeout guards against
excessive relay requests on very long threads.
---------------------------------------------------------------- */
const REPLY_FETCH_MAX_DEPTH = 5;
const REPLY_FETCH_LEVEL_TIMEOUT_MS = 8000;
function fetchRepliesForLevelFromCache(parentIds) {
return queryCache({ kinds: [1], '#e': parentIds }).catch(() => []).then((r) => Array.isArray(r) ? r : []);
}
function fetchRepliesForLevelFromRelays(parentIds) {
return new Promise((resolve) => {
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
resolve([]);
}, REPLY_FETCH_LEVEL_TIMEOUT_MS);
ndkFetchEvents({ kinds: [1], '#e': parentIds })
.then((events) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(Array.isArray(events) ? events : []);
})
.catch((error) => {
console.warn('[post-feed.html] Recursive reply level fetch failed:', error?.message || error);
if (settled) return;
settled = true;
clearTimeout(timer);
resolve([]);
});
});
}
async function fetchRepliesRecursive(rootEventId, maxDepth = REPLY_FETCH_MAX_DEPTH) {
if (!rootEventId) return;
let currentLevelIds = [rootEventId];
for (let depth = 0; depth < maxDepth; depth += 1) {
if (currentLevelIds.length === 0) break;
// Cache first — fast display of whatever we already have.
const cachedEvents = await fetchRepliesForLevelFromCache(currentLevelIds);
const nextLevelIds = [];
for (const evt of cachedEvents) {
if (upsertReply(evt)) {
nextLevelIds.push(evt.id);
}
}
if (cachedEvents.length > 0) renderReplies();
// Then relay hydration for this level.
const relayEvents = await fetchRepliesForLevelFromRelays(currentLevelIds);
for (const evt of relayEvents) {
if (upsertReply(evt)) {
nextLevelIds.push(evt.id);
}
}
if (relayEvents.length > 0) renderReplies();
// Move to the next depth level: replies to the replies we just found.
// (upsertReply dedupes via replyIds, so ids already seen are skipped
// on the next iteration by virtue of not being newly added — but we
// still need to query for their children, so include all newly-found
// ids here.)
currentLevelIds = nextLevelIds;
}
// Final render to ensure consistency.
renderReplies();
}
async function fetchReplies() {
if (!targetEventId) return;
// Cache first.
try {
const cachedReplies = await queryCache({ kinds: [1], '#e': [targetEventId] });
if (Array.isArray(cachedReplies)) {
cachedReplies.forEach((evt) => upsertReply(evt));
renderReplies();
}
} catch (_) {}
// Then relay fetch.
void ndkFetchEvents({ kinds: [1], '#e': [targetEventId] }).then((fresh) => {
if (Array.isArray(fresh)) {
fresh.forEach((evt) => upsertReply(evt));
}
renderReplies();
}).catch((error) => {
console.warn('[post-feed.html] Reply fetch failed:', error?.message || error);
});
await fetchRepliesRecursive(targetEventId, REPLY_FETCH_MAX_DEPTH);
}
/* ================================================================
@@ -887,7 +1240,16 @@
}
if (isReplyToThread(evt)) {
appendReplyIfNew(evt);
const wasNew = upsertReply(evt);
if (wasNew) {
renderReplies();
// A newly-seen reply may itself have nested replies we haven't
// fetched yet. Trigger a one-level-deep fetch for replies to it
// so nested replies appear in realtime too.
fetchRepliesRecursive(evt.id, 1).catch((err) => {
console.warn('[post-feed.html] Realtime nested fetch failed:', err?.message || err);
});
}
}
});