- Added computeReplyLevels() to compute reply depth from NIP-10 e tags - Added buildDepthFirstOrder() for depth-first thread sorting (root first, children grouped by parent chronologically) - Render vertical indent lines using CSS variables (var(--muted-color) for non-selected, var(--accent-color) for focused post) - Added collapse/expand functionality with hidden reply count - Added focused post highlight via ?focus=<eventId> URL param - Real-time subscriptions preserved — new replies appear in correct threaded position - All colors use CSS variables — no hardcoded colors
1286 lines
43 KiB
HTML
1286 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%;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
/* 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;
|
|
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="divThreadHeader">Replies</div>
|
|
<div id="divReplies"></div>
|
|
</div>
|
|
<div id="divComposer"></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,
|
|
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 collapsedReplyIds = new Set(); // Phase 2.5 — collapsed subtree roots (in-memory)
|
|
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,
|
|
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 target event id in an e tag.
|
|
return eTags.some((t) => t[1] === targetEventId);
|
|
}
|
|
|
|
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, skipping
|
|
descendants of any event id in `collapsedIds`.
|
|
---------------------------------------------------------------- */
|
|
function buildDepthFirstOrder(events, levels, rootEventId, collapsedIds) {
|
|
const childrenOf = new Map(); // parentId -> [event, ...]
|
|
const rootChildren = [];
|
|
|
|
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 });
|
|
if (!collapsedIds.has(child.id)) {
|
|
visit(child.id, childLevel);
|
|
}
|
|
}
|
|
}
|
|
|
|
visit(rootEventId, 0);
|
|
return ordered;
|
|
}
|
|
|
|
/* ----------------------------------------------------------------
|
|
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;
|
|
}
|
|
|
|
/* ----------------------------------------------------------------
|
|
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, collapsedReplyIds);
|
|
|
|
const visibleCount = ordered.length;
|
|
const hiddenCount = replies.length - visibleCount;
|
|
const totalLabel = `${replies.length} ${replies.length === 1 ? 'reply' : 'replies'}` + (hiddenCount > 0 ? ` (${hiddenCount} hidden)` : '');
|
|
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);
|
|
|
|
// 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.
|
|
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);
|
|
|
|
// 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);
|
|
});
|
|
|
|
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' });
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ----------------------------------------------------------------
|
|
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
|
|
================================================================
|
|
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.');
|
|
}
|
|
});
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
/* ================================================================
|
|
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)) {
|
|
appendReplyIfNew(evt);
|
|
}
|
|
});
|
|
|
|
// 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>
|