Files
client/www/post-feed.html
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

1271 lines
43 KiB
HTML

<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>THREAD</title>
<link rel="stylesheet" href="./css/client.css" />
<link rel="stylesheet" href="./css/post-composer.css" />
<link rel="stylesheet" href="./css/dot-menu.css" />
<!-- Initialize theme BEFORE any components load -->
<script>
(function () {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.documentElement.classList.add('dark-mode');
if (document.body) document.body.classList.add('dark-mode');
}
})();
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="./js/vendor/svg.min.js"></script>
<style>
#divBody {
flex-direction: column !important;
flex-wrap: nowrap !important;
align-items: center !important;
justify-content: flex-start !important;
align-content: flex-start !important;
gap: 10px;
overflow-y: auto;
overflow-anchor: none;
}
#divBackBar,
#divHint,
#divThread,
#divComposer {
width: 80%;
min-width: 300px;
max-width: 700px;
}
#divBackBar {
display: flex;
align-items: center;
gap: 8px;
margin-top: 5px;
}
#btnBack {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 90%;
color: var(--primary-color);
background: var(--secondary-color);
border: 1px solid var(--primary-color);
border-radius: 8px;
cursor: pointer;
text-decoration: none;
}
#btnBack:hover {
color: var(--accent-color);
border-color: var(--accent-color);
}
#divHint {
text-align: center;
color: var(--muted-color);
font-size: 80%;
margin-top: 5px;
}
#divThread {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 10px;
}
#divThreadHeader {
font-size: 90%;
color: var(--muted-color);
padding: 4px 0;
border-bottom: 1px solid var(--border-color);
}
#divReplies {
display: flex;
flex-direction: column;
gap: 10px;
}
#divComposer {
margin-top: 5px;
margin-bottom: 20px;
}
#divComposer .post-composer-wrapper {
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%;
}
.threadError {
text-align: center;
padding: 40px 20px;
color: var(--muted-color);
font-size: 110%;
}
.threadError .threadErrorTitle {
font-size: 140%;
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>
<body>
<div id="divSvgHam" class="divHeaderButtons"></div>
<div id="divHeader">
<div id="divHeaderFlexLeft"></div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText">THREAD</div>
</div>
<div id="divHeaderFlexRight"></div>
</div>
<div id="divBody">
<div id="divBackBar">
<a id="btnBack" href="./feed2.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>
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox"></div>
<div id="divFooterRight" class="divFooterBox"></div>
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
</div>
<div id="divSideNav">
<div id="divSideNavHeader"></div>
<div id="divSideNavBody">
<div id="divFiles"></div>
</div>
<div id="divAiSection" class="sidenavSection">
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
<div id="divAiList" class="sidenavSectionList">
<div id="divAiProvidersList">No saved providers yet.</div>
</div>
</div>
<div id="divRelaySection">
<div id="divRelaySectionTitle">リレー</div>
<div id="divRelayList">Loading relays...</div>
</div>
<div id="divBlossomSection">
<div id="divBlossomSectionTitle">ブロッサム</div>
<div id="divBlossomList">Loading blossom servers...</div>
</div>
<div id="divVersionBar">
<span id="versionDisplay">loading...</span>
<div id="divVersionBarButtons">
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
<div id="themeToggleHamburgerContainer"></div>
</button>
<button id="logoutButton" title="Logout">
<div id="logoutHamburgerContainer"></div>
</button>
</div>
</div>
</div>
<script src="./nostr.bundle.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {
initNDKPage,
getPubkey,
injectHeaderAvatar,
injectHeaderLoginButton,
subscribe,
disconnect,
getVersion,
updateVersionDisplay,
queryCache,
ndkFetchEvents,
fetchCachedProfile,
storeProfile,
publishEvent,
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
walletGetMints,
walletGetBalance,
getRelayData,
getUserSettings,
patchUserSettings,
onUserSettings,
ensureMuteListLoaded,
isEventMuted,
addMute
} from './js/init-ndk.mjs';
import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs';
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
import { initPostCards } from './js/post-interactions2.mjs';
import { mountComposer } from './js/post-composer.mjs';
/* ================================================================
URL PARAMETER PARSING
================================================================
Parse the `event` URL parameter. Accepts hex event ID, nevent,
or note (bech32). Decodes nevent/note to hex using nostr-tools
(window.NostrTools.nip19.decode), same pattern as post.html.
================================================================ */
const urlParams = new URLSearchParams(window.location.search);
const eventParamRaw = (urlParams.get('event') || '').trim();
function resolveEventParamToId(eventValue) {
const raw = String(eventValue || '').trim();
if (!raw) return null;
const normalized = raw.toLowerCase().startsWith('nostr:') ? raw.slice(6) : raw;
if (/^[0-9a-fA-F]{64}$/.test(normalized)) {
return normalized.toLowerCase();
}
try {
const decoded = window?.NostrTools?.nip19?.decode?.(normalized);
if (!decoded) return null;
if (decoded.type === 'note' && typeof decoded.data === 'string' && /^[0-9a-fA-F]{64}$/.test(decoded.data)) {
return decoded.data.toLowerCase();
}
if (decoded.type === 'nevent' && decoded.data?.id && /^[0-9a-fA-F]{64}$/.test(decoded.data.id)) {
return decoded.data.id.toLowerCase();
}
} catch (_) {}
return null;
}
function toNostrNoteRef(postId) {
try {
return window?.NostrTools?.nip19?.noteEncode
? window.NostrTools.nip19.noteEncode(postId)
: postId;
} catch (_) {
return postId;
}
}
/* ================================================================
GLOBAL STATE
================================================================ */
let currentPubkey = null;
let updateIntervalId = null;
let targetEventId = null;
let originalPost = null; // the kind 1 event being threaded
let originalPostEl = null; // DOM element for the original post
let replyComposerInstance = null;
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;
let themeToggleHamburger = null;
let isDarkMode = false;
let isNavOpen = false;
let unsubscribeUserSettings = null;
let relayActivityListenersBound = false;
let authedPageInitialized = false;
const divBody = document.getElementById('divBody');
const divSideNav = document.getElementById('divSideNav');
const divFooterCenter = document.getElementById('divFooterCenter');
const divFooterRight = document.getElementById('divFooterRight');
const divHint = document.getElementById('divHint');
const divOriginalPost = document.getElementById('divOriginalPost');
const divReplies = document.getElementById('divReplies');
const divThreadHeader = document.getElementById('divThreadHeader');
const divComposer = document.getElementById('divComposer');
const btnBack = document.getElementById('btnBack');
/* ================================================================
ERROR / EMPTY STATE RENDERING
================================================================ */
function renderErrorState(title, message) {
divOriginalPost.innerHTML = '';
divReplies.innerHTML = '';
divThreadHeader.style.display = 'none';
divComposer.style.display = 'none';
divHint.textContent = '';
divBody.insertAdjacentHTML(
'beforeend',
`<div class="threadError" style="width:80%;min-width:300px;max-width:700px;">
<div class="threadErrorTitle">${title}</div>
<div>${message}</div>
</div>`
);
}
/* ================================================================
INLINE COMPOSER (for replies on individual posts)
================================================================
Same pattern as feed2.html / post.html: per-post inline composer
used for quote replies. The bottom composer is the primary reply
entry point for the thread.
================================================================ */
const inlineComposerInstances = new Map(); // postId -> { hostEl, instance, tags }
function focusInlineComposer(hostEl, content = '') {
if (!hostEl) return false;
hostEl.innerText = content || '';
hostEl.focus();
document.dispatchEvent(new Event('selectionchange'));
hostEl.dispatchEvent(new Event('input', { bubbles: true }));
window.requestAnimationFrame(() => {
hostEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
return true;
}
function getOrCreateInlineComposer(postId, postEl) {
if (!postId || !postEl) return null;
const existing = inlineComposerInstances.get(postId);
if (existing?.hostEl?.isConnected && existing?.instance) {
return existing;
}
const hostEl = document.createElement('div');
hostEl.className = 'inlinePostComposerInput';
hostEl.dataset.inlineComposerFor = postId;
postEl.appendChild(hostEl);
const entry = {
hostEl,
tags: [],
instance: null
};
const instance = mountComposer(hostEl, {
currentPubkey,
followedProfiles: [],
showUploadIcon: true,
showPreview: true,
autoHideOnSubmit: true,
hideOnEscape: true,
onSubmit: async (content) => {
const text = String(content || '').trim();
if (!text) return;
await publishEvent({
kind: 1,
content: text,
tags: entry.tags,
created_at: Math.floor(Date.now() / 1000)
});
}
});
entry.instance = instance;
inlineComposerInstances.set(postId, entry);
return entry;
}
/* ================================================================
COMMENT / QUOTE INTENT HANDLERS
================================================================
- Comment button on the original post or a reply opens a new tab
to post-feed.html?event=<thatPostId> (same as feed2.html).
- Quote button opens an inline composer pre-filled with nostr:note.
================================================================ */
function handleCommentIntent({ postId }) {
if (!postId) return false;
window.open('./post-feed.html?event=' + encodeURIComponent(postId), '_blank');
return true;
}
function handleQuoteIntent({ postId, postPubkey }) {
const postEl = document.querySelector(`.divPostItem[data-post-id="${postId}"]`);
if (!postEl) return false;
const noteRef = toNostrNoteRef(postId);
const entry = getOrCreateInlineComposer(postId, postEl);
if (!entry) return false;
entry.tags = [
['e', postId, '', 'mention'],
['p', postPubkey],
['q', postId]
];
if (entry.instance?.show) entry.instance.show();
return focusInlineComposer(entry.hostEl, `nostr:${noteRef}`);
}
async function handleMuteIntent({ eventData }) {
const targetPubkey = String(eventData?.pubkey || '').trim();
if (!targetPubkey || targetPubkey === currentPubkey) return;
const ok = window.confirm(`Mute ${targetPubkey.slice(0, 8)}${targetPubkey.slice(-4)}?`);
if (!ok) return;
await addMute('p', targetPubkey, false);
// Remove any replies by the muted author from the view.
for (let i = replies.length - 1; i >= 0; i -= 1) {
if (replies[i]?.pubkey === targetPubkey) {
replyIds.delete(replies[i]?.id);
replies.splice(i, 1);
}
}
renderReplies();
}
/* ================================================================
POST CARDS (post-interactions2.mjs)
================================================================ */
const cards = initPostCards({
subscribe,
publishEvent,
getPubkey,
ndkFetchEvents,
queryCache,
fetchCachedProfile,
storeProfile,
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
walletGetMints,
walletGetBalance,
getRelayData,
getUserSettings,
patchUserSettings,
onCommentIntent: handleCommentIntent,
onQuoteIntent: handleQuoteIntent,
onMuteIntent: handleMuteIntent
});
/* ================================================================
HAMBURGER MENU
================================================================ */
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = 'clamp(400px, 50vw, 600px)';
isNavOpen = true;
if (hamburgerInstance) hamburgerInstance.animateTo('arrow_left');
if (!logoutHamburger) {
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
logoutHamburger.animateTo('x');
}
if (!themeToggleHamburger) {
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
const savedTheme = localStorage.getItem('theme');
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
}
function closeNav() {
divSideNav.style.width = '0vw';
divSideNav.style.zIndex = -1;
isNavOpen = false;
if (hamburgerInstance) hamburgerInstance.animateTo('burger');
}
function toggleNav() {
isNavOpen ? closeNav() : openNav();
}
/* ================================================================
ORIGINAL POST RENDERING
================================================================ */
function renderOriginalPost(evt) {
if (!evt?.id) return;
originalPost = evt;
divOriginalPost.innerHTML = '';
if (isEventMuted(evt)) {
divOriginalPost.innerHTML = '<div class="threadError"><div class="threadErrorTitle">Muted post</div><div>This post is from a muted author.</div></div>';
return;
}
originalPostEl = cards.createCard(evt, { currentPubkey, autoWire: false, autoplayVideo: false });
divOriginalPost.appendChild(originalPostEl);
cards.wireInteractions([evt.id], { currentPubkey });
divHint.textContent = `Thread for ${evt.id.slice(0, 8)}`;
divFooterRight.textContent = '1 post';
}
/* ================================================================
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 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) {
if (!evt?.id || replyIds.has(evt.id)) return false;
if (evt.id === targetEventId) return false;
if (isEventMuted(evt)) return false;
if (!isReplyToThread(evt)) return false;
replyIds.add(evt.id);
replies.push(evt);
return true;
}
/* ----------------------------------------------------------------
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;
// 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();
if (replies.length === 0) {
divThreadHeader.textContent = 'No replies yet';
divFooterRight.textContent = originalPost ? '1 post' : '0 posts';
return;
}
const levels = computeReplyLevels(replies, targetEventId);
const ordered = buildDepthFirstOrder(replies, levels, targetEventId);
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 });
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(wiredIds, { currentPubkey });
divFooterRight.textContent = `${1 + replies.length} posts`;
// 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' });
});
}
}
}
/* ================================================================
BOTTOM REPLY COMPOSER
================================================================
Tags the original post (['e', postId, '', 'reply']) and the
original author (['p', postPubkey]) per NIP-10 reply convention.
================================================================ */
async function mountReplyComposer() {
if (!divComposer) return;
divComposer.innerHTML = '';
divComposer.style.display = '';
const hostEl = document.createElement('div');
hostEl.className = 'topPostComposerInput';
divComposer.appendChild(hostEl);
const focusReplyComposer = () => {
requestAnimationFrame(() => {
hostEl.focus();
const selection = window.getSelection?.();
if (!selection) return;
const range = document.createRange();
range.selectNodeContents(hostEl);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
});
};
replyComposerInstance = mountComposer(hostEl, {
currentPubkey,
followedProfiles: [],
showUploadIcon: true,
showPreview: true,
autoHideOnSubmit: false,
onSubmit: async (content) => {
const text = String(content || '').trim();
if (!text) return;
if (!originalPost?.id) return;
const tags = [
['e', originalPost.id, '', 'reply'],
['p', originalPost.pubkey]
];
await publishEvent({
kind: 1,
content: text,
tags,
created_at: Math.floor(Date.now() / 1000)
});
replyComposerInstance?.clear?.();
focusReplyComposer();
}
});
focusReplyComposer();
}
/* ================================================================
FETCH ORIGINAL POST + REPLIES
================================================================ */
async function fetchOriginalPost() {
// Cache first for fast display, then hydrate from relays.
let matched = null;
try {
const cached = await queryCache({ ids: [targetEventId], limit: 1 });
matched = Array.isArray(cached)
? cached.find((evt) => evt?.id === targetEventId) || cached[0]
: null;
} catch (_) {}
if (matched) {
renderOriginalPost(matched);
await mountReplyComposer();
await fetchReplies();
} else {
divHint.textContent = 'Loading event…';
}
// Relay hydration.
void ndkFetchEvents({ ids: [targetEventId], limit: 1 }).then((relayEvents) => {
const relayMatch = Array.isArray(relayEvents)
? relayEvents.find((evt) => evt?.id === targetEventId) || relayEvents[0]
: null;
if (relayMatch) {
if (!originalPost) {
renderOriginalPost(relayMatch);
mountReplyComposer();
fetchReplies();
} else {
// Update in place if a fresher copy arrives.
renderOriginalPost(relayMatch);
}
return;
}
if (!matched) {
renderErrorState('Post not found', 'Could not find this post on your relays. It may have been deleted or your relays do not have it.');
}
}).catch((error) => {
console.warn('[post-feed.html] Event relay hydration failed:', error?.message || error);
if (!matched) {
renderErrorState('Post not found', 'Could not find this post on your relays.');
}
});
}
/* ----------------------------------------------------------------
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;
await fetchRepliesRecursive(targetEventId, REPLY_FETCH_MAX_DEPTH);
}
/* ================================================================
REAL-TIME SUBSCRIPTION
================================================================
Subscribe to the original post (in case it updates) and to new
replies (kind 1 with an e tag pointing at the post id).
================================================================ */
function startRealtimeSubscriptions() {
if (!targetEventId) return;
const liveSince = Math.floor(Date.now() / 1000) - (30 * 24 * 3600);
// Subscribe to the original post itself.
subscribe(
{ kinds: [1], ids: [targetEventId], since: liveSince },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
// Subscribe to replies referencing this post.
subscribe(
{ kinds: [1], '#e': [targetEventId] },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
}
/* ================================================================
AUTH / PAGE INIT
================================================================ */
async function initializeAuthenticatedPageFeatures() {
if (!currentPubkey) {
await injectHeaderLoginButton();
return;
}
if (authedPageInitialized) return;
await injectHeaderAvatar(currentPubkey);
console.log('[post-feed.html] Authenticated as:', currentPubkey);
try {
await getUserSettings();
} catch (error) {
console.warn('[post-feed.html] getUserSettings failed:', error);
}
if (!unsubscribeUserSettings) {
unsubscribeUserSettings = onUserSettings(() => {
// Settings updated; no page-specific settings to re-render yet.
});
}
await ensureMuteListLoaded();
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
const updateFooter = async () => {
try {
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
divFooterCenter.innerHTML = '';
} catch (error) {
console.error('[post-feed.html] Error updating footer:', error);
}
};
await updateFooter();
if (!updateIntervalId) {
updateIntervalId = setInterval(updateFooter, 1000);
}
if (!relayActivityListenersBound) {
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity } = event.detail;
setRelayActivityState(relayUrl, activity);
});
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'relayActivity') {
const { relayUrl, activity } = event.data;
setRelayActivityState(relayUrl, activity);
}
});
relayActivityListenersBound = true;
}
authedPageInitialized = true;
}
async function logout() {
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
cards.destroy();
disconnect();
if (window.NOSTR_LOGIN_LITE?.logout) {
await window.NOSTR_LOGIN_LITE.logout();
}
if (unsubscribeUserSettings) {
unsubscribeUserSettings();
unsubscribeUserSettings = null;
}
localStorage.clear();
sessionStorage.clear();
location.reload(true);
}
/* ================================================================
MAIN INITIALIZATION
================================================================ */
(async function main() {
try {
const versionInfo = await getVersion();
document.getElementById('versionDisplay').textContent = versionInfo.VERSION;
console.log(`[post-feed.html ${versionInfo.VERSION}] Loading...`);
// Validate the event parameter before anything else.
targetEventId = resolveEventParamToId(eventParamRaw);
if (!targetEventId) {
if (!eventParamRaw) {
renderErrorState('Missing post', 'No event parameter provided. Use <code>post-feed.html?event=<id></code>.');
} else {
renderErrorState('Invalid event', `Could not decode event parameter: <code>${eventParamRaw}</code>`);
}
return;
}
// Hamburger + version bar buttons.
initHamburgerMenu();
document.getElementById('divSvgHam')?.addEventListener('click', toggleNav);
document.getElementById('themeToggleButton')?.addEventListener('click', () => {
isDarkMode = !isDarkMode;
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
document.documentElement.classList.toggle('dark-mode', isDarkMode);
document.body.classList.toggle('dark-mode', isDarkMode);
if (themeToggleHamburger) {
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
});
document.getElementById('logoutButton')?.addEventListener('click', logout);
// Auth — required mode (we need the worker to fetch events).
await initNDKPage();
currentPubkey = await getPubkey();
await initializeAuthenticatedPageFeatures();
// Listen for live events from the worker.
window.addEventListener('ndkEvent', (e) => {
const evt = e.detail;
if (!evt || evt.kind !== 1) return;
if (evt.id === targetEventId) {
if (!originalPost) {
renderOriginalPost(evt);
mountReplyComposer();
fetchReplies();
}
return;
}
if (isReplyToThread(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);
});
}
}
});
// Fetch the post + replies, then start realtime subscriptions.
await fetchOriginalPost();
startRealtimeSubscriptions();
await updateVersionDisplay();
console.log('[post-feed.html] Initialization complete');
} catch (error) {
console.error('[post-feed.html] Initialization failed:', error);
renderErrorState('Error', error?.message || String(error));
}
})();
</script>
</body>
</html>