Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
508952a1ea | ||
|
|
15ef106eb5 | ||
|
|
a42edcf6d6 | ||
|
|
0267233f87 | ||
|
|
6b45092bc8 | ||
|
|
6d39188967 |
@@ -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
|
||||
|
||||
@@ -154,33 +155,28 @@ Each `│` is a vertical line drawn with CSS `border-left` on a container div. T
|
||||
|
||||
### Tasks
|
||||
|
||||
- [ ] **2.5.1** Add a `computeReplyLevels(events, rootEventId)` function to `post-feed.html` (or a shared module):
|
||||
- [x] **2.5.1** Add a `computeReplyLevels(events, rootEventId)` function to `post-feed.html`:
|
||||
- Build a map of `eventId → parentEventId` from the `e` tags (NIP-10 marked reply tags)
|
||||
- For each event, compute its level by walking up the parent chain to the root
|
||||
- Root post = level 0, direct reply = level 1, reply to reply = level 2, etc.
|
||||
- Handle missing parents gracefully (if a parent isn't in the fetched set, treat as level 1)
|
||||
- Return a `Map<eventId, level>`
|
||||
|
||||
- [ ] **2.5.2** Sort replies in depth-first order (like Amethyst's `replyLevelSignature`):
|
||||
- [x] **2.5.2** Sort replies in depth-first order (like Amethyst's `replyLevelSignature`):
|
||||
- Root post first
|
||||
- Then replies grouped by parent, in chronological order within each group
|
||||
- This makes the vertical lines visually contiguous — a reply's children appear right below it
|
||||
|
||||
- [ ] **2.5.3** Render each reply with vertical indent lines:
|
||||
- Wrap each reply in a container div with `padding-left: calc(level * 12px)` (or similar)
|
||||
- For each level 0..N-1, add a vertical line using `border-left: 2px solid var(--muted-color)` on a positioned div
|
||||
- [x] **2.5.3** Render each reply with vertical indent lines:
|
||||
- Wrap each reply in a container div with `padding-left: calc(level * 12px)`
|
||||
- For each level 0..N-1, add a vertical line using positioned divs with `background: var(--muted-color)`
|
||||
- The last line (level N-1) uses `var(--accent-color)` if this is the focused post, otherwise `var(--muted-color)`
|
||||
- Use CSS pseudo-elements or nested divs for the lines
|
||||
|
||||
- [ ] **2.5.4** Add collapse/expand functionality:
|
||||
- Each reply has a collapse toggle (small button or click on the vertical line)
|
||||
- Collapsing a reply hides all of its descendants
|
||||
- Show a count of hidden replies when collapsed (e.g., "3 replies hidden")
|
||||
- Collapsed state stored in a Set in memory
|
||||
- [x] ~~**2.5.4** Add collapse/expand functionality~~ — **Removed per user request.** All replies are shown, no collapse.
|
||||
|
||||
- [ ] **2.5.5** Highlight the focused post:
|
||||
- If the URL has a `#<eventId>` hash or a `focus=<eventId>` param, scroll to and highlight that post
|
||||
- The highlighted post's vertical line uses `var(--accent-color)`
|
||||
- [x] **2.5.5** Highlight the focused post:
|
||||
- If the URL has a `focus=<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)
|
||||
|
||||
|
||||
@@ -1388,6 +1388,68 @@ a.nostr-embed-preview-text:hover {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Zap rail selector (Phase 4 — nutzap vs Lightning) */
|
||||
.zap-rail-toggle {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.zap-rail-option {
|
||||
flex: 1;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--background-color);
|
||||
color: var(--muted-color);
|
||||
font-family: var(--font-family);
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.zap-rail-option.active {
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.zap-rail-single {
|
||||
display: block;
|
||||
border: 1px solid var(--accent-color);
|
||||
border-radius: 8px;
|
||||
background: var(--background-color);
|
||||
color: var(--accent-color);
|
||||
font-family: var(--font-family);
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.zap-selector-balance {
|
||||
font-size: 12px;
|
||||
color: var(--muted-color);
|
||||
}
|
||||
|
||||
.zap-selector-warning {
|
||||
font-size: 12px;
|
||||
color: var(--accent-color);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.zap-selector-mint-info {
|
||||
font-size: 12px;
|
||||
color: var(--muted-color);
|
||||
}
|
||||
|
||||
.zap-selector-error {
|
||||
font-size: 13px;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.zap-send:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ************************************************************************* */
|
||||
/* AUTHOR HEADER */
|
||||
/* ************************************************************************* */
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
@@ -639,12 +634,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 +709,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>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
promptZapDetails,
|
||||
prepareZapInvoiceForEvent,
|
||||
resolveNutzapSpecForPubkey,
|
||||
resolveZapCapabilities,
|
||||
extractZapAmount as extractZapAmountFromReceipt,
|
||||
extractZapComment as extractZapCommentFromReceipt
|
||||
} from './zaps.mjs';
|
||||
@@ -957,6 +958,8 @@ let publishEventFn = null;
|
||||
let walletPayInvoiceFn = null;
|
||||
let walletSendNutzapFn = null;
|
||||
let walletFetchMintListFn = null;
|
||||
let walletGetMintsFn = null;
|
||||
let walletGetBalanceFn = null;
|
||||
let getRelayDataFn = null;
|
||||
let getUserSettingsFn = null;
|
||||
let patchUserSettingsFn = null;
|
||||
@@ -1079,7 +1082,11 @@ export function renderFooterRow(eventId, eventData, options = {}) {
|
||||
container.appendChild(timeEl);
|
||||
|
||||
footerRow.appendChild(container);
|
||||
|
||||
|
||||
// Async, non-blocking zap capability badges (Phase 3).
|
||||
// Rendered after the bar is in the DOM so feed rendering is never blocked.
|
||||
setTimeout(() => attachZapCapabilityBadges(eventId, eventData.pubkey), 0);
|
||||
|
||||
return footerRow;
|
||||
}
|
||||
|
||||
@@ -1145,6 +1152,10 @@ export function renderInteractionBar(postId, postData, options = {}) {
|
||||
container.appendChild(commentItem);
|
||||
container.appendChild(quoteItem);
|
||||
|
||||
// Async, non-blocking zap capability badges (Phase 3).
|
||||
// Rendered after the bar is in the DOM so feed rendering is never blocked.
|
||||
setTimeout(() => attachZapCapabilityBadges(postId, postData.pubkey), 0);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
@@ -1302,6 +1313,135 @@ function updateNutzapCountDisplay(el, state = {}) {
|
||||
el.classList.toggle('has-count', hasCount);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ZAP CAPABILITY BADGES (Phase 3 — feed-upgrade)
|
||||
// =============================================================================
|
||||
//
|
||||
// Small visual indicators appended next to the zap button showing which zap
|
||||
// rails are available for the post's author:
|
||||
// ⚡ (accent) — recipient has a lud16 Lightning address
|
||||
// 🥜 (accent) — recipient has a kind 10019 mint list with a shared mint
|
||||
// 🥜 (muted) — recipient has a kind 10019 but no shared mint with sender
|
||||
//
|
||||
// Badges are rendered asynchronously after the interaction bar is in the DOM
|
||||
// so feed rendering is never blocked. Each badge carries a `title` tooltip
|
||||
// with human-readable details. Colors come exclusively from CSS variables.
|
||||
|
||||
/**
|
||||
* Build a single capability badge element.
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.glyph - emoji to display (e.g. '⚡' or '🥜')
|
||||
* @param {string} opts.colorVar - CSS variable name for color ('--accent-color' | '--muted-color')
|
||||
* @param {string} opts.title - tooltip text
|
||||
* @returns {HTMLSpanElement}
|
||||
*/
|
||||
function createZapCapabilityBadge({ glyph, colorVar, title }) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'zap-capability-badge';
|
||||
badge.textContent = glyph;
|
||||
badge.title = title;
|
||||
badge.style.color = `var(${colorVar})`;
|
||||
badge.style.fontSize = '75%';
|
||||
badge.style.lineHeight = '1';
|
||||
badge.style.marginLeft = '2px';
|
||||
badge.style.userSelect = 'none';
|
||||
badge.style.pointerEvents = 'none';
|
||||
badge.setAttribute('aria-hidden', 'true');
|
||||
return badge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the tooltip text for the nutzap capability.
|
||||
* @param {Object} caps - result from resolveZapCapabilities
|
||||
* @returns {string}
|
||||
*/
|
||||
function nutzapCapabilityTooltip(caps) {
|
||||
if (caps.canNutzap && caps.sharedMints.length > 0) {
|
||||
const first = caps.sharedMints[0];
|
||||
const extra = caps.sharedMints.length > 1
|
||||
? ` (+${caps.sharedMints.length - 1} more)`
|
||||
: '';
|
||||
return `Can nutzap via ${first}${extra}`;
|
||||
}
|
||||
if (caps.hasMintList) {
|
||||
return 'No shared mints — Lightning only';
|
||||
}
|
||||
return 'No nutzap mint list';
|
||||
}
|
||||
|
||||
/**
|
||||
* Append capability badges to an interaction bar for a given post.
|
||||
* Looks up the bar by `data-post-id` so it works regardless of which render
|
||||
* path created it (renderFooterRow or renderInteractionBar).
|
||||
*
|
||||
* @param {string} postId - the event id used as data-post-id
|
||||
* @param {string} pubkey - the post author's pubkey
|
||||
*/
|
||||
function attachZapCapabilityBadges(postId, pubkey) {
|
||||
if (!postId || !pubkey) return;
|
||||
if (typeof walletFetchMintListFn !== 'function' && typeof fetchProfile !== 'function') {
|
||||
return; // nothing to resolve with
|
||||
}
|
||||
|
||||
resolveZapCapabilities(pubkey, {
|
||||
fetchProfile,
|
||||
fetchMintList: walletFetchMintListFn || undefined,
|
||||
getSenderMints: walletGetMintsFn || undefined,
|
||||
logPrefix: '[post-interactions][zap-caps]'
|
||||
}).then((caps) => {
|
||||
const bar = document.querySelector(`.divPostInteractions[data-post-id="${postId}"]`);
|
||||
if (!bar) return; // post may have been removed from the DOM
|
||||
|
||||
// Avoid double-rendering if badges already exist.
|
||||
if (bar.querySelector('.zap-capability-badge')) return;
|
||||
|
||||
const zapItem = bar.querySelector('.interaction-item.zap');
|
||||
const anchor = zapItem || bar; // fall back to the bar itself
|
||||
const host = zapItem ? zapItem : bar;
|
||||
|
||||
const badges = [];
|
||||
|
||||
if (caps.canLightning) {
|
||||
badges.push(createZapCapabilityBadge({
|
||||
glyph: '⚡',
|
||||
colorVar: '--accent-color',
|
||||
title: caps.lud16 ? `Lightning: ${caps.lud16}` : 'Lightning zap available'
|
||||
}));
|
||||
}
|
||||
|
||||
if (caps.hasMintList) {
|
||||
const hasShared = caps.canNutzap && caps.sharedMints.length > 0;
|
||||
badges.push(createZapCapabilityBadge({
|
||||
glyph: '🥜',
|
||||
colorVar: hasShared ? '--accent-color' : '--muted-color',
|
||||
title: nutzapCapabilityTooltip(caps)
|
||||
}));
|
||||
}
|
||||
|
||||
if (badges.length === 0) {
|
||||
// Only show an explicit "no capability" indicator on the zap item
|
||||
// itself, and only when we actually had enough info to resolve
|
||||
// (i.e. fetchProfile ran). This keeps the bar uncluttered when the
|
||||
// wallet/ndk isn't ready yet.
|
||||
if (zapItem && caps.lud16 === null && !caps.hasMintList) {
|
||||
const noCap = createZapCapabilityBadge({
|
||||
glyph: '⊘',
|
||||
colorVar: '--muted-color',
|
||||
title: 'No zap capability'
|
||||
});
|
||||
host.appendChild(noCap);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const badge of badges) {
|
||||
host.appendChild(badge);
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.warn('[post-interactions][zap-caps] attach failed', error?.message || error);
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ANIMATION HANDLING
|
||||
// =============================================================================
|
||||
@@ -1323,6 +1463,66 @@ function isZapTimeoutError(error) {
|
||||
return msg.includes('timeout');
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a zap/melt payment error into a user-friendly message.
|
||||
* Hides raw mint API errors while still being informative.
|
||||
* @param {Error|*} error
|
||||
* @returns {string}
|
||||
*/
|
||||
function friendlyZapErrorMessage(error) {
|
||||
const raw = String(error?.message || error || '').trim();
|
||||
const msg = raw.toLowerCase();
|
||||
|
||||
if (msg.includes('melt') || msg.includes('swap') || msg.includes('proof')) {
|
||||
return 'Mint couldn\'t route Lightning payment. The mint may not have enough Lightning liquidity.';
|
||||
}
|
||||
if (msg.includes('timeout')) {
|
||||
return 'Payment timed out. The mint may be slow or unresponsive.';
|
||||
}
|
||||
if (msg.includes('insufficient') || msg.includes('balance')) {
|
||||
return 'Insufficient Cashu balance for this payment.';
|
||||
}
|
||||
return raw || 'Zap failed.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the current user's Cashu balance in sats.
|
||||
* Returns null when the wallet function is unavailable or the fetch fails.
|
||||
* @returns {Promise<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
|
||||
// =============================================================================
|
||||
@@ -1587,8 +1787,49 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
|
||||
throw new Error('Cashu wallet payer is not configured on this page');
|
||||
}
|
||||
|
||||
// --- Resolve zap capabilities (Phase 4) ---------------------------------
|
||||
// Determine which rails (Lightning / Nutzap) are available for this
|
||||
// recipient so the dialog can present a rail selector.
|
||||
let zapCaps = null;
|
||||
try {
|
||||
zapCaps = await resolveZapCapabilities(postPubkey, {
|
||||
fetchProfile,
|
||||
fetchMintList: walletFetchMintListFn || undefined,
|
||||
getSenderMints: walletGetMintsFn || undefined,
|
||||
logPrefix: '[post-interactions][zap-caps]'
|
||||
});
|
||||
} catch (capsError) {
|
||||
console.warn('[post-interactions][zap] handleZapClick:caps-failed', capsError?.message || capsError);
|
||||
}
|
||||
|
||||
const canLightning = Boolean(zapCaps?.canLightning);
|
||||
const canNutzap = Boolean(zapCaps?.canNutzap);
|
||||
const sharedMints = Array.isArray(zapCaps?.sharedMints) ? zapCaps.sharedMints : [];
|
||||
|
||||
console.log('[post-interactions][zap] handleZapClick:caps', {
|
||||
recipient: String(postPubkey || '').slice(0, 8) + '…',
|
||||
canLightning,
|
||||
canNutzap,
|
||||
sharedMints: sharedMints.length
|
||||
});
|
||||
|
||||
// --- Fetch the user's Cashu balance for the dialog ----------------------
|
||||
let balanceSats = null;
|
||||
if (typeof walletGetBalanceFn === 'function') {
|
||||
try {
|
||||
const balanceResult = await walletGetBalanceFn();
|
||||
const balance = Number(balanceResult?.balance ?? balanceResult?.sats ?? balanceResult);
|
||||
if (Number.isFinite(balance) && balance >= 0) {
|
||||
balanceSats = Math.floor(balance);
|
||||
}
|
||||
} catch (balanceError) {
|
||||
console.warn('[post-interactions][zap] handleZapClick:balance-failed', balanceError?.message || balanceError);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Resolve the nutzap spec (still needed for the actual send) ---------
|
||||
let nutzapSpec = null;
|
||||
if (typeof walletFetchMintListFn === 'function') {
|
||||
if (canNutzap && typeof walletFetchMintListFn === 'function') {
|
||||
try {
|
||||
nutzapSpec = await resolveNutzapSpecForPubkey(postPubkey, {
|
||||
fetchMintList: walletFetchMintListFn,
|
||||
@@ -1610,7 +1851,9 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
|
||||
defaultAmountSats: zapDefaults.amountSats,
|
||||
defaultComment: zapDefaults.comment,
|
||||
defaultShouldZap: true,
|
||||
onSaveDefault: saveZapDefaultsToSettings
|
||||
onSaveDefault: saveZapDefaultsToSettings,
|
||||
railOptions: { canLightning, canNutzap, sharedMints },
|
||||
balanceSats
|
||||
});
|
||||
if (!details) {
|
||||
console.log('[post-interactions][zap] handleZapClick:cancelled-by-user');
|
||||
@@ -1620,7 +1863,8 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
|
||||
console.log('[post-interactions][zap] handleZapClick:details', {
|
||||
amountSats: details.amountSats,
|
||||
hasComment: Boolean(String(details.comment || '').trim()),
|
||||
shouldZap: Boolean(details.shouldZap)
|
||||
shouldZap: Boolean(details.shouldZap),
|
||||
rail: details.rail
|
||||
});
|
||||
|
||||
const state = getPostState(postId);
|
||||
@@ -1652,9 +1896,55 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nutzapSpec?.mints?.length > 0 && typeof walletSendNutzapFn === 'function') {
|
||||
// --- Pre-flight balance check (Phase 5) --------------------------------
|
||||
// A final safety net right before sending, in case the user ignored the
|
||||
// dialog warning or the balance changed between dialog and send.
|
||||
const zapAmount = Math.max(0, Math.floor(Number(details.amountSats || 0)));
|
||||
const preflightBalance = await fetchWalletBalanceSats();
|
||||
|
||||
if (preflightBalance !== null) {
|
||||
if (preflightBalance < zapAmount) {
|
||||
const msg = `Insufficient balance. Current: ${preflightBalance} sats, needed: ${zapAmount} sats`;
|
||||
console.warn('[post-interactions][zap] handleZapClick:insufficient-balance', {
|
||||
balance: preflightBalance, needed: zapAmount
|
||||
});
|
||||
if (countEl) {
|
||||
countEl.textContent = 'low';
|
||||
countEl.title = msg;
|
||||
setTimeout(() => {
|
||||
updateZapCountDisplay(itemEl, getPostState(postId));
|
||||
}, 2200);
|
||||
}
|
||||
window.alert(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// "Barely sufficient" warning: remaining balance would be less than
|
||||
// 10% of the current balance.
|
||||
const remaining = preflightBalance - zapAmount;
|
||||
const tenPercent = Math.floor(preflightBalance * 0.10);
|
||||
if (remaining > 0 && remaining < tenPercent) {
|
||||
const confirmMsg = `This will use most of your balance (${remaining} sats remaining). Continue?`;
|
||||
const proceed = window.confirm(confirmMsg);
|
||||
if (!proceed) {
|
||||
console.log('[post-interactions][zap] handleZapClick:declined-low-balance');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Choose the rail based on the user's selection (Phase 4) ------------
|
||||
// Prefer the explicit rail chosen in the dialog. Fall back to capability
|
||||
// detection for backward compatibility when no rail was returned.
|
||||
const useNutzapRail = details.rail === 'nutzap'
|
||||
? (nutzapSpec?.mints?.length > 0 && typeof walletSendNutzapFn === 'function')
|
||||
: (details.rail === 'lightning'
|
||||
? false
|
||||
: (nutzapSpec?.mints?.length > 0 && typeof walletSendNutzapFn === 'function'));
|
||||
|
||||
if (useNutzapRail) {
|
||||
if (countEl) countEl.textContent = 'send…';
|
||||
console.log('[post-interactions][zap] handleZapClick:sending-nutzap');
|
||||
console.log('[post-interactions][zap] handleZapClick:sending-nutzap', { rail: details.rail });
|
||||
const nutzapResult = await walletSendNutzapFn({
|
||||
amount: details.amountSats,
|
||||
memo: details.comment,
|
||||
@@ -1702,15 +1992,28 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
|
||||
itemEl.classList.remove('zap-success');
|
||||
updateZapCountDisplay(itemEl, state);
|
||||
}, 1800);
|
||||
|
||||
// --- Post-zap balance refresh (Phase 5) --------------------------------
|
||||
// Refresh the wallet balance display so the user sees their updated
|
||||
// balance after a successful zap. Non-blocking — failures are logged
|
||||
// but never surface to the user since the zap itself succeeded.
|
||||
refreshWalletBalanceDisplay().catch((refreshError) => {
|
||||
console.warn('[post-interactions][zap] handleZapClick:balance-refresh-failed', refreshError?.message || refreshError);
|
||||
});
|
||||
} catch (error) {
|
||||
const timeoutFailure = isZapTimeoutError(error);
|
||||
const friendlyMessage = friendlyZapErrorMessage(error);
|
||||
console.error('[post-interactions][zap] handleZapClick:failed', error);
|
||||
if (countEl) {
|
||||
countEl.textContent = timeoutFailure ? 'check' : 'fail';
|
||||
countEl.title = friendlyMessage;
|
||||
setTimeout(() => {
|
||||
updateZapCountDisplay(itemEl, getPostState(postId));
|
||||
}, timeoutFailure ? 2800 : 2200);
|
||||
}
|
||||
// Surface a user-friendly error message. Avoids exposing raw mint API
|
||||
// errors while still being informative.
|
||||
window.alert(friendlyMessage);
|
||||
itemEl.classList.remove('zap-success');
|
||||
} finally {
|
||||
clearInterval(zapPulseInterval);
|
||||
@@ -2308,6 +2611,8 @@ export function initInteractions(ndkFunctions = {}) {
|
||||
walletPayInvoice,
|
||||
walletSendNutzap,
|
||||
walletFetchMintList,
|
||||
walletGetMints,
|
||||
walletGetBalance,
|
||||
getRelayData,
|
||||
getUserSettings,
|
||||
patchUserSettings,
|
||||
@@ -2325,6 +2630,8 @@ export function initInteractions(ndkFunctions = {}) {
|
||||
walletPayInvoiceFn = typeof walletPayInvoice === 'function' ? walletPayInvoice : null;
|
||||
walletSendNutzapFn = typeof walletSendNutzap === 'function' ? walletSendNutzap : null;
|
||||
walletFetchMintListFn = typeof walletFetchMintList === 'function' ? walletFetchMintList : null;
|
||||
walletGetMintsFn = typeof walletGetMints === 'function' ? walletGetMints : null;
|
||||
walletGetBalanceFn = typeof walletGetBalance === 'function' ? walletGetBalance : null;
|
||||
getRelayDataFn = typeof getRelayData === 'function' ? getRelayData : null;
|
||||
getUserSettingsFn = typeof getUserSettings === 'function' ? getUserSettings : null;
|
||||
patchUserSettingsFn = typeof patchUserSettings === 'function' ? patchUserSettings : null;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"VERSION": "v0.7.26",
|
||||
"VERSION_NUMBER": "0.7.26",
|
||||
"BUILD_DATE": "2026-06-25T23:53:36.767Z"
|
||||
"VERSION": "v0.7.32",
|
||||
"VERSION_NUMBER": "0.7.32",
|
||||
"BUILD_DATE": "2026-06-26T00:57:34.005Z"
|
||||
}
|
||||
|
||||
307
www/js/zaps.mjs
307
www/js/zaps.mjs
@@ -249,6 +249,40 @@ function closeActiveZapModal(result, resolver) {
|
||||
resolver(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a string for safe insertion into HTML text/attribute content.
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
const HTML_ESCAPE_RE = /[&<>"']/g;
|
||||
function escapeHtml(value) {
|
||||
const amp = '&' + 'amp;';
|
||||
const lt = '&' + 'lt;';
|
||||
const gt = '&' + 'gt;';
|
||||
const quot = '&' + 'quot;';
|
||||
const apos = '&' + '#39;';
|
||||
const map = { '&': amp, '<': lt, '>': gt, '"': quot, "'": apos };
|
||||
return String(value || '').replace(HTML_ESCAPE_RE, (ch) => map[ch]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the default zap rail based on availability and amount.
|
||||
* - nutzap available and amount < 1000 sats → nutzap
|
||||
* - nutzap available and amount >= 1000 sats → lightning
|
||||
* - nutzap not available → lightning (if available)
|
||||
* - neither available → null
|
||||
* @param {Object} railOptions - { canLightning, canNutzap }
|
||||
* @param {number} amountSats
|
||||
* @returns {'nutzap'|'lightning'|null}
|
||||
*/
|
||||
function chooseDefaultRail(railOptions, amountSats) {
|
||||
const { canLightning = false, canNutzap = false } = railOptions || {};
|
||||
if (canNutzap && Number(amountSats) < 1000) return 'nutzap';
|
||||
if (canLightning) return 'lightning';
|
||||
if (canNutzap) return 'nutzap';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function promptZapDetails(options = {}) {
|
||||
if (activeZapModalEl) {
|
||||
return Promise.resolve(null);
|
||||
@@ -258,9 +292,19 @@ export function promptZapDetails(options = {}) {
|
||||
defaultAmountSats = 21,
|
||||
defaultComment = '',
|
||||
defaultShouldZap = true,
|
||||
onSaveDefault = null
|
||||
onSaveDefault = null,
|
||||
railOptions = null,
|
||||
balanceSats = null
|
||||
} = options;
|
||||
|
||||
// Normalize rail options.
|
||||
const canLightning = Boolean(railOptions?.canLightning);
|
||||
const canNutzap = Boolean(railOptions?.canNutzap);
|
||||
const sharedMints = Array.isArray(railOptions?.sharedMints)
|
||||
? railOptions.sharedMints.map((m) => String(m || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
const hasAnyRail = canLightning || canNutzap;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const initialAmount = Number(defaultAmountSats);
|
||||
const normalizedDefaultAmount = Number.isFinite(initialAmount) && initialAmount > 0
|
||||
@@ -268,11 +312,44 @@ export function promptZapDetails(options = {}) {
|
||||
: 21;
|
||||
const normalizedDefaultComment = String(defaultComment || '').trim();
|
||||
|
||||
// Resolve the initial rail selection.
|
||||
let selectedRail = hasAnyRail
|
||||
? chooseDefaultRail({ canLightning, canNutzap }, normalizedDefaultAmount)
|
||||
: null;
|
||||
|
||||
const showRailToggle = canLightning && canNutzap;
|
||||
const primarySharedMint = sharedMints[0] || '';
|
||||
|
||||
const balanceDisplay = (Number.isFinite(Number(balanceSats)) && Number(balanceSats) >= 0)
|
||||
? `<div class="zap-selector-balance">Cashu balance: ${Math.floor(Number(balanceSats))} sats</div>`
|
||||
: '';
|
||||
const noRailMessage = !hasAnyRail
|
||||
? `<div class="zap-selector-error">Recipient cannot receive zaps</div>`
|
||||
: '';
|
||||
|
||||
const railToggleHtml = showRailToggle
|
||||
? `<div class="zap-rail-toggle" role="group" aria-label="Zap rail">
|
||||
<button type="button" class="zap-rail-option" data-rail="nutzap">🥜 Nutzap</button>
|
||||
<button type="button" class="zap-rail-option" data-rail="lightning">⚡ Lightning</button>
|
||||
</div>`
|
||||
: (hasAnyRail
|
||||
? `<div class="zap-rail-single zap-rail-option active" data-rail="${selectedRail === 'nutzap' ? 'nutzap' : 'lightning'}">
|
||||
${selectedRail === 'nutzap' ? '🥜 Nutzap' : '⚡ Lightning'}
|
||||
</div>`
|
||||
: '');
|
||||
|
||||
const sharedMintInfoHtml = (canNutzap && primarySharedMint)
|
||||
? `<div class="zap-selector-mint-info" data-rail-info="nutzap">Will send via ${escapeHtml(primarySharedMint)}</div>`
|
||||
: '';
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'zap-selector-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="zap-selector" role="dialog" aria-modal="true" aria-label="Send zap">
|
||||
<div class="zap-selector-title">Send Zap</div>
|
||||
${noRailMessage}
|
||||
${balanceDisplay}
|
||||
${railToggleHtml}
|
||||
<div class="zap-selector-presets">
|
||||
<button type="button" class="zap-preset" data-amount="21">⚡21</button>
|
||||
<button type="button" class="zap-preset" data-amount="100">⚡100</button>
|
||||
@@ -284,9 +361,11 @@ 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>
|
||||
${sharedMintInfoHtml}
|
||||
<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' : ''} />
|
||||
@@ -295,7 +374,7 @@ export function promptZapDetails(options = {}) {
|
||||
<div class="zap-selector-actions">
|
||||
<button type="button" class="zap-cancel">Cancel</button>
|
||||
<button type="button" class="zap-save-default">Save as default</button>
|
||||
<button type="button" class="zap-send">Send</button>
|
||||
<button type="button" class="zap-send" ${hasAnyRail ? '' : 'disabled'}>Send</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -307,6 +386,9 @@ export function promptZapDetails(options = {}) {
|
||||
const cancelBtn = overlay.querySelector('.zap-cancel');
|
||||
const enableZapEl = overlay.querySelector('.zap-selector-enable');
|
||||
const presetButtons = Array.from(overlay.querySelectorAll('.zap-preset'));
|
||||
const railOptionButtons = Array.from(overlay.querySelectorAll('.zap-rail-option'));
|
||||
const warningEl = overlay.querySelector('[data-warning="insufficient-balance"]');
|
||||
const mintInfoEl = overlay.querySelector('[data-rail-info="nutzap"]');
|
||||
|
||||
const parseCurrentValues = () => {
|
||||
const amountSats = Number(amountEl?.value || 0);
|
||||
@@ -315,11 +397,41 @@ export function promptZapDetails(options = {}) {
|
||||
return { amountSats, comment, shouldZap };
|
||||
};
|
||||
|
||||
const updateRailSelection = (rail) => {
|
||||
if (!rail || !hasAnyRail) return;
|
||||
selectedRail = rail;
|
||||
railOptionButtons.forEach((btn) => {
|
||||
btn.classList.toggle('active', btn.dataset?.rail === rail);
|
||||
});
|
||||
// Show/hide shared mint info based on selected rail.
|
||||
if (mintInfoEl) {
|
||||
mintInfoEl.style.display = (rail === 'nutzap') ? '' : 'none';
|
||||
}
|
||||
};
|
||||
|
||||
const updateBalanceWarning = () => {
|
||||
if (!warningEl) return;
|
||||
const { amountSats } = parseCurrentValues();
|
||||
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;
|
||||
}
|
||||
// Re-evaluate default rail when amount changes via preset.
|
||||
if (showRailToggle) {
|
||||
const newDefault = chooseDefaultRail({ canLightning, canNutzap }, Number(button?.dataset?.amount || 0));
|
||||
if (newDefault) updateRailSelection(newDefault);
|
||||
}
|
||||
updateBalanceWarning();
|
||||
};
|
||||
|
||||
presetButtons.forEach((button) => {
|
||||
@@ -328,6 +440,22 @@ export function promptZapDetails(options = {}) {
|
||||
|
||||
amountEl?.addEventListener('input', () => {
|
||||
presetButtons.forEach((btn) => btn.classList.remove('active'));
|
||||
// Re-evaluate default rail as the user types.
|
||||
if (showRailToggle) {
|
||||
const { amountSats } = parseCurrentValues();
|
||||
const newDefault = chooseDefaultRail({ canLightning, canNutzap }, amountSats);
|
||||
if (newDefault) updateRailSelection(newDefault);
|
||||
}
|
||||
updateBalanceWarning();
|
||||
});
|
||||
|
||||
railOptionButtons.forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const rail = button?.dataset?.rail;
|
||||
if (rail === 'nutzap' || rail === 'lightning') {
|
||||
updateRailSelection(rail);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
saveDefaultBtn?.addEventListener('click', async () => {
|
||||
@@ -362,12 +490,13 @@ export function promptZapDetails(options = {}) {
|
||||
});
|
||||
|
||||
sendBtn?.addEventListener('click', () => {
|
||||
if (!hasAnyRail) return;
|
||||
const { amountSats, comment, shouldZap } = parseCurrentValues();
|
||||
if (shouldZap && (!Number.isFinite(amountSats) || amountSats <= 0)) {
|
||||
amountEl?.focus();
|
||||
return;
|
||||
}
|
||||
closeActiveZapModal({ amountSats, comment, shouldZap }, resolve);
|
||||
closeActiveZapModal({ amountSats, comment, shouldZap, rail: selectedRail }, resolve);
|
||||
});
|
||||
|
||||
cancelBtn?.addEventListener('click', () => closeActiveZapModal(null, resolve));
|
||||
@@ -390,6 +519,9 @@ export function promptZapDetails(options = {}) {
|
||||
} else {
|
||||
presetButtons.forEach((btn) => btn.classList.remove('active'));
|
||||
}
|
||||
// Initialize rail selection and dependent UI state.
|
||||
updateRailSelection(selectedRail);
|
||||
updateBalanceWarning();
|
||||
amountEl?.focus();
|
||||
});
|
||||
}
|
||||
@@ -506,3 +638,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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -148,37 +156,6 @@
|
||||
background: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Click target overlaying each line so users can collapse a subtree
|
||||
by clicking the vertical line for that level. */
|
||||
.reply-thread-line-hit {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 12px;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.reply-collapse-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
margin: 2px 0 4px 0;
|
||||
font-size: 80%;
|
||||
color: var(--muted-color);
|
||||
background: var(--secondary-color);
|
||||
border: 1px solid var(--muted-color);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.reply-collapse-toggle:hover {
|
||||
color: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.reply-item-focused {
|
||||
outline: 2px solid var(--accent-color);
|
||||
outline-offset: 2px;
|
||||
@@ -200,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">
|
||||
@@ -275,6 +252,8 @@
|
||||
walletPayInvoice,
|
||||
walletSendNutzap,
|
||||
walletFetchMintList,
|
||||
walletGetMints,
|
||||
walletGetBalance,
|
||||
getRelayData,
|
||||
getUserSettings,
|
||||
patchUserSettings,
|
||||
@@ -349,7 +328,6 @@
|
||||
const replies = []; // flat list of reply events
|
||||
const replyIds = new Set(); // dedupe by event id
|
||||
const renderedReplyIds = new Set();
|
||||
const collapsedReplyIds = new Set(); // Phase 2.5 — collapsed subtree roots (in-memory)
|
||||
const focusedEventId = (urlParams.get('focus') || '').trim().toLowerCase() || null;
|
||||
|
||||
let hamburgerInstance = null;
|
||||
@@ -518,6 +496,8 @@
|
||||
walletPayInvoice,
|
||||
walletSendNutzap,
|
||||
walletFetchMintList,
|
||||
walletGetMints,
|
||||
walletGetBalance,
|
||||
getRelayData,
|
||||
getUserSettings,
|
||||
patchUserSettings,
|
||||
@@ -607,8 +587,11 @@
|
||||
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) {
|
||||
@@ -742,80 +725,71 @@
|
||||
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, skipping
|
||||
descendants of any event id in `collapsedIds`.
|
||||
Returns an array of { event, level } in display order.
|
||||
---------------------------------------------------------------- */
|
||||
function buildDepthFirstOrder(events, levels, rootEventId, collapsedIds) {
|
||||
const childrenOf = new Map(); // parentId -> [event, ...]
|
||||
const rootChildren = [];
|
||||
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);
|
||||
}
|
||||
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));
|
||||
}
|
||||
// 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();
|
||||
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 });
|
||||
if (!collapsedIds.has(child.id)) {
|
||||
visit(child.id, childLevel);
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
return ordered;
|
||||
}
|
||||
visit(rootEventId, 0);
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Count descendants of a given event id (for the "N replies hidden"
|
||||
label). Uses the same children map logic.
|
||||
---------------------------------------------------------------- */
|
||||
function countDescendants(eventId, events) {
|
||||
const childrenOf = new Map();
|
||||
for (const evt of events) {
|
||||
if (!evt?.id || evt.id === targetEventId) continue;
|
||||
const pid = getDirectParentId(evt);
|
||||
const parentKey = pid && events.some((e) => e?.id === pid) ? pid : targetEventId;
|
||||
if (!childrenOf.has(parentKey)) childrenOf.set(parentKey, []);
|
||||
childrenOf.get(parentKey).push(evt);
|
||||
}
|
||||
let count = 0;
|
||||
const stack = [eventId];
|
||||
const seen = new Set();
|
||||
while (stack.length) {
|
||||
const cur = stack.pop();
|
||||
if (seen.has(cur)) continue;
|
||||
seen.add(cur);
|
||||
const kids = childrenOf.get(cur) || [];
|
||||
for (const k of kids) {
|
||||
count += 1;
|
||||
stack.push(k.id);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
// 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
|
||||
@@ -831,11 +805,9 @@
|
||||
}
|
||||
|
||||
const levels = computeReplyLevels(replies, targetEventId);
|
||||
const ordered = buildDepthFirstOrder(replies, levels, targetEventId, collapsedReplyIds);
|
||||
const ordered = buildDepthFirstOrder(replies, levels, targetEventId);
|
||||
|
||||
const visibleCount = ordered.length;
|
||||
const hiddenCount = replies.length - visibleCount;
|
||||
const totalLabel = `${replies.length} ${replies.length === 1 ? 'reply' : 'replies'}` + (hiddenCount > 0 ? ` (${hiddenCount} hidden)` : '');
|
||||
const totalLabel = `${replies.length} ${replies.length === 1 ? 'reply' : 'replies'}`;
|
||||
divThreadHeader.textContent = totalLabel;
|
||||
|
||||
const wiredIds = [];
|
||||
@@ -864,26 +836,6 @@
|
||||
line.classList.add('selected');
|
||||
}
|
||||
container.appendChild(line);
|
||||
|
||||
// Click target on the line area — clicking the line for level i
|
||||
// collapses the subtree of the ancestor at that level. We find
|
||||
// the ancestor by walking up the parent chain i times.
|
||||
const hit = document.createElement('div');
|
||||
hit.className = 'reply-thread-line-hit';
|
||||
hit.style.left = `${i * 12}px`;
|
||||
hit.title = 'Click to collapse/expand this branch';
|
||||
hit.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
const ancestorId = findAncestorAtLevel(reply.id, i, levels);
|
||||
if (!ancestorId) return;
|
||||
if (collapsedReplyIds.has(ancestorId)) {
|
||||
collapsedReplyIds.delete(ancestorId);
|
||||
} else {
|
||||
collapsedReplyIds.add(ancestorId);
|
||||
}
|
||||
renderReplies();
|
||||
});
|
||||
container.appendChild(hit);
|
||||
}
|
||||
|
||||
// The post card itself.
|
||||
@@ -894,28 +846,6 @@
|
||||
}
|
||||
container.appendChild(replyEl);
|
||||
|
||||
// Collapse toggle button for replies that have children.
|
||||
const descendantCount = countDescendants(reply.id, replies);
|
||||
if (descendantCount > 0) {
|
||||
const toggle = document.createElement('button');
|
||||
toggle.className = 'reply-collapse-toggle';
|
||||
toggle.type = 'button';
|
||||
const collapsed = collapsedReplyIds.has(reply.id);
|
||||
toggle.textContent = collapsed
|
||||
? `▸ ${descendantCount} ${descendantCount === 1 ? 'reply' : 'replies'} hidden`
|
||||
: `▾ collapse`;
|
||||
toggle.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (collapsedReplyIds.has(reply.id)) {
|
||||
collapsedReplyIds.delete(reply.id);
|
||||
} else {
|
||||
collapsedReplyIds.add(reply.id);
|
||||
}
|
||||
renderReplies();
|
||||
});
|
||||
container.appendChild(toggle);
|
||||
}
|
||||
|
||||
divReplies.appendChild(container);
|
||||
renderedReplyIds.add(reply.id);
|
||||
wiredIds.push(reply.id);
|
||||
@@ -935,37 +865,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
findAncestorAtLevel(eventId, targetLevel, levels)
|
||||
----------------------------------------------------------------
|
||||
Walks up the parent chain to find the ancestor of `eventId` that
|
||||
sits at `targetLevel`. Used so clicking a vertical line at
|
||||
position i collapses the ancestor whose subtree that line
|
||||
represents.
|
||||
---------------------------------------------------------------- */
|
||||
function findAncestorAtLevel(eventId, targetLevel, levels) {
|
||||
let cur = eventId;
|
||||
const byId = new Map(replies.map((r) => [r.id, r]));
|
||||
if (originalPost) byId.set(originalPost.id, originalPost);
|
||||
const guard = new Set();
|
||||
while (cur && !guard.has(cur)) {
|
||||
guard.add(cur);
|
||||
const curLevel = levels.get(cur);
|
||||
if (curLevel === targetLevel) return cur;
|
||||
const evt = byId.get(cur);
|
||||
if (!evt) return null;
|
||||
const pid = getDirectParentId(evt);
|
||||
if (!pid) return null;
|
||||
cur = pid;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function appendReplyIfNew(evt) {
|
||||
if (!upsertReply(evt)) return;
|
||||
renderReplies();
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
BOTTOM REPLY COMPOSER
|
||||
================================================================
|
||||
@@ -1075,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);
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
@@ -1264,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);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user