Files
client/www/post.html
Laan Tungir ee0077063d Implement append-only rendering for feed and post pages (task 1.1)
Split renderFeed() into full rebuild + prependPostCard() on both feed.html
and post.html. New live events now prepend a single card at the top of the
feed without rebuilding existing cards, eliminating O(n) DOM rebuilds on
every incoming event. DOM is trimmed to displayCount on overflow with
renderedPostIds kept in sync. Full rebuild path preserved for See More,
mute removal, bootstrap completion, and event mode.

Also added implementation checklist to plans/long-running-memory-bloat-fix.md.
2026-07-01 07:59:39 -04:00

1026 lines
32 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>POST</title>
<link rel="stylesheet" href="./css/client.css" />
<link rel="stylesheet" href="./css/post-composer.css" />
<link rel="stylesheet" href="./css/dot-menu.css" />
<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" />
<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;
}
#divComposer,
#divProfileCard,
#divHint,
#divFeed,
#btnSeeMore {
width: 80%;
min-width: 300px;
max-width: 700px;
}
#divProfileCard {
display: none;
background: var(--card-background);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 12px;
box-sizing: border-box;
}
.profileCardHead {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.profileCardAvatar {
width: 52px;
height: 52px;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--border-color);
background: var(--secondary-color);
}
.profileCardName {
font-weight: 700;
line-height: 1.2;
overflow-wrap: anywhere;
}
.profileCardPubkey {
color: var(--muted-color);
font-size: 80%;
overflow-wrap: anywhere;
margin-top: 2px;
}
.profileCardAbout {
color: var(--text-color);
white-space: pre-wrap;
overflow-wrap: anywhere;
font-size: 95%;
}
.profileCardLoading {
color: var(--muted-color);
font-size: 90%;
}
#divHint {
text-align: center;
color: var(--muted-color);
font-size: 80%;
margin-top: 5px;
}
#divComposer {
margin-top: 5px;
}
#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;
}
#divFeed {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 10px;
}
#btnSeeMore {
display: none;
margin-bottom: 20px;
}
.divPostItem .post-composer-wrapper {
width: 100%;
}
</style>
</head>
<body>
<div id="divSvgHam" class="divHeaderButtons"></div>
<div id="divHeader">
<div id="divHeaderFlexLeft"></div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText">POST</div>
</div>
<div id="divHeaderFlexRight"></div>
</div>
<div id="divBody">
<div id="divComposer"></div>
<div id="divProfileCard"></div>
<div id="divHint">Loading your posts…</div>
<div id="divFeed"></div>
<button id="btnSeeMore" class="btn">See More</button>
</div>
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox">
<span id="spanBroadcastStatus"></span>
</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,
subscribe,
disconnect,
getVersion,
queryCache,
ndkFetchEvents,
fetchCachedProfile,
storeProfile,
publishEvent,
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
getRelayData,
getUserSettings,
patchUserSettings,
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';
const INITIAL_POSTS_LOAD = 20;
const SEE_MORE_INCREMENT = 20;
const MIN_BOOTSTRAP_POSTS = 10;
const BOOTSTRAP_WINDOWS_SECONDS = [24 * 3600, 7 * 24 * 3600, 30 * 24 * 3600, 90 * 24 * 3600];
const BOOTSTRAP_PER_WINDOW_LIMIT = 120;
const urlParams = new URLSearchParams(window.location.search);
const profileParamRaw = (urlParams.get('profile') || '').trim();
const npubParamRaw = (urlParams.get('npub') || '').trim();
const pubkeyParamRaw = (urlParams.get('pubkey') || '').trim();
const eventParamRaw = (urlParams.get('event') || '').trim();
const isProfileMode = profileParamRaw.length > 0;
const isEventMode = eventParamRaw.length > 0;
let currentPubkey = null;
let targetProfilePubkey = null;
let targetEventId = null;
let updateIntervalId = null;
let hamburgerInstance = null;
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
let isNavOpen = false;
const posts = [];
const postIds = new Set();
const renderedPostIds = new Set();
let displayCount = INITIAL_POSTS_LOAD;
const divBody = document.getElementById('divBody');
const divComposer = document.getElementById('divComposer');
const divSideNav = document.getElementById('divSideNav');
const divFooterRight = document.getElementById('divFooterRight');
const divFeed = document.getElementById('divFeed');
const divHint = document.getElementById('divHint');
const divProfileCard = document.getElementById('divProfileCard');
const btnSeeMore = document.getElementById('btnSeeMore');
const inlineComposerInstances = new Map(); // postId -> { hostEl, instance, tags }
let isBootstrapping = true;
const bufferedLiveEvents = [];
function escapeHtml(value) {
const text = String(value || '');
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
function resolveProfileParamToPubkey(profileValue) {
const value = String(profileValue || '').trim();
if (!value) return null;
if (/^[0-9a-fA-F]{64}$/.test(value)) {
return value.toLowerCase();
}
try {
const decoded = window?.NostrTools?.nip19?.decode?.(value);
if (!decoded) return null;
if (decoded.type === 'npub' && typeof decoded.data === 'string' && /^[0-9a-fA-F]{64}$/.test(decoded.data)) {
return decoded.data.toLowerCase();
}
if (decoded.type === 'nprofile' && decoded.data?.pubkey && /^[0-9a-fA-F]{64}$/.test(decoded.data.pubkey)) {
return decoded.data.pubkey.toLowerCase();
}
} catch (_) {}
return null;
}
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 encodeNpub(pubkey) {
try {
return window?.NostrTools?.nip19?.npubEncode
? window.NostrTools.nip19.npubEncode(pubkey)
: '';
} catch (_) {
return '';
}
}
async function fetchProfileMetadata(pubkey) {
const cached = await fetchCachedProfile(pubkey).catch(() => null);
if (cached) return cached;
const metaEvents = await ndkFetchEvents({
kinds: [0],
authors: [pubkey],
limit: 1
}).catch(() => []);
if (!Array.isArray(metaEvents) || metaEvents.length === 0) return null;
const latest = [...metaEvents].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0];
if (!latest?.content) return null;
try {
const parsed = JSON.parse(latest.content);
if (parsed && typeof parsed === 'object') {
await storeProfile(pubkey, parsed).catch(() => {});
return parsed;
}
} catch (_) {}
return null;
}
async function renderProfileCard() {
if (!divProfileCard) return;
if (!isProfileMode || !targetProfilePubkey) {
divProfileCard.style.display = 'none';
return;
}
divProfileCard.style.display = 'block';
divProfileCard.innerHTML = '<div class="profileCardLoading">Loading profile…</div>';
const profile = await fetchProfileMetadata(targetProfilePubkey);
const displayName = profile?.display_name || profile?.name || `${targetProfilePubkey.slice(0, 8)}${targetProfilePubkey.slice(-8)}`;
const about = profile?.about || '';
const picture = profile?.picture || '';
const npub = encodeNpub(targetProfilePubkey);
divProfileCard.innerHTML = `
<div class="profileCardHead">
<img class="profileCardAvatar" src="${escapeHtml(picture || './favicon/favicon-dots2.ico')}" alt="avatar" />
<div>
<div class="profileCardName">${escapeHtml(displayName)}</div>
<div class="profileCardPubkey">${escapeHtml(npub || targetProfilePubkey)}</div>
</div>
</div>
<div class="profileCardAbout">${escapeHtml(about || 'No profile bio.')}</div>
`;
}
function applyPageModeUI() {
const headerLabel = document.querySelector('#divHeader .divHeaderText');
if (isEventMode) {
if (headerLabel) headerLabel.textContent = 'EVENT';
if (divComposer) divComposer.style.display = 'none';
if (divHint) divHint.textContent = 'Loading event…';
return;
}
if (isProfileMode) {
if (headerLabel) headerLabel.textContent = 'PROFILE';
if (divComposer) divComposer.style.display = 'none';
if (divHint) divHint.textContent = 'Loading profile posts…';
} else {
if (headerLabel) headerLabel.textContent = 'POST';
if (divComposer) divComposer.style.display = '';
if (divHint) divHint.textContent = 'Loading your posts…';
}
}
function toNostrNoteRef(postId) {
try {
return window?.NostrTools?.nip19?.noteEncode
? window.NostrTools.nip19.noteEncode(postId)
: postId;
} catch (_) {
return postId;
}
}
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;
}
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 }) {
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);
for (let i = posts.length - 1; i >= 0; i -= 1) {
if (posts[i]?.pubkey === targetPubkey) {
postIds.delete(posts[i]?.id);
posts.splice(i, 1);
}
}
renderFeed();
}
const cards = initPostCards({
subscribe,
publishEvent,
getPubkey,
ndkFetchEvents,
queryCache,
fetchCachedProfile,
storeProfile,
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
getRelayData,
getUserSettings,
patchUserSettings,
onCommentIntent: handleComposerCommentIntent,
onQuoteIntent: handleComposerQuoteIntent,
onMuteIntent: handleMuteIntent
});
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();
}
function isOriginalPost(event) {
const eTags = event.tags?.filter(t => t[0] === 'e') || [];
if (eTags.length === 0) return true;
return eTags.every(t => t[3] === 'mention');
}
function upsertPost(evt) {
if (!evt?.id || postIds.has(evt.id) || !isOriginalPost(evt)) return false;
if (isEventMuted(evt)) return false;
postIds.add(evt.id);
posts.push(evt);
return true;
}
function renderFeed() {
posts.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
const toShow = posts.slice(0, displayCount);
divFeed.innerHTML = '';
renderedPostIds.clear();
toShow.forEach((post) => {
const postEl = cards.createCard(post, { currentPubkey, autoWire: false, autoplayVideo: false });
divFeed.appendChild(postEl);
renderedPostIds.add(post.id);
});
cards.wireInteractions(toShow.map(p => p.id), { currentPubkey });
if (isEventMode) {
btnSeeMore.style.display = 'none';
const shownId = toShow[0]?.id || targetEventId || '';
divHint.textContent = toShow.length > 0
? `Showing event ${shownId.slice(0, 8)}`
: 'Event not found yet.';
divFooterRight.textContent = `${toShow.length} event${toShow.length === 1 ? '' : 's'}`;
return;
}
btnSeeMore.style.display = posts.length > displayCount ? 'block' : 'none';
divHint.textContent = posts.length > 0
? `${posts.length} posts`
: (isProfileMode ? 'No posts yet.' : 'No posts yet — write one above.');
divFooterRight.textContent = `${posts.length} posts`;
}
function prependPostCard(post) {
if (!post?.id || renderedPostIds.has(post.id)) return false;
// Event mode should never prepend — it uses renderSingleEvent / renderFeed.
if (isEventMode) {
renderFeed();
return false;
}
// If feed is empty or not yet rendered, fall back to full rebuild.
if (divFeed.children.length === 0 || renderedPostIds.size === 0) {
renderFeed();
return false;
}
// Only prepend if the post is newer than the first currently visible post.
const firstChild = divFeed.firstElementChild;
const firstPostId = firstChild ? firstChild.getAttribute('data-post-id') : null;
const firstPost = firstPostId ? posts.find((p) => p.id === firstPostId) : null;
if (!firstPost || (post.created_at || 0) < (firstPost.created_at || 0)) {
// Post is older than the first visible post (or unknown) — full rebuild for safety.
renderFeed();
return false;
}
const postEl = cards.createCard(post, { currentPubkey, autoWire: false, autoplayVideo: false });
divFeed.insertBefore(postEl, divFeed.firstChild);
renderedPostIds.add(post.id);
cards.wireInteractions([post.id], { currentPubkey });
// Trim DOM to displayCount.
while (divFeed.children.length > displayCount) {
const lastChild = divFeed.lastElementChild;
if (!lastChild) break;
const removedId = lastChild.getAttribute('data-post-id');
if (removedId) renderedPostIds.delete(removedId);
divFeed.removeChild(lastChild);
}
btnSeeMore.style.display = posts.length > displayCount ? 'block' : 'none';
divHint.textContent = posts.length > 0
? `${posts.length} posts`
: (isProfileMode ? 'No posts yet.' : 'No posts yet — write one above.');
divFooterRight.textContent = `${posts.length} posts`;
return true;
}
function renderSingleEvent(evt) {
if (!evt?.id) return;
posts.length = 0;
postIds.clear();
renderedPostIds.clear();
displayCount = 1;
if (isEventMuted(evt)) {
divFeed.innerHTML = '';
divHint.textContent = 'This event is muted.';
divFooterRight.textContent = '0 events';
return;
}
postIds.add(evt.id);
posts.push(evt);
renderFeed();
}
async function fetchOwnPostsWindow({ since, limit = BOOTSTRAP_PER_WINDOW_LIMIT, renderAfterCache = false }) {
const filters = { kinds: [1], authors: [targetProfilePubkey], limit };
if (typeof since === 'number' && Number.isFinite(since) && since > 0) {
filters.since = since;
}
const cached = await queryCache(filters).catch(() => []);
cached.forEach((evt) => {
upsertPost(evt);
});
if (renderAfterCache && posts.length > 0) {
renderFeed();
}
const fresh = await ndkFetchEvents(filters).catch(() => []);
fresh.forEach((evt) => {
upsertPost(evt);
});
}
async function bootstrapInitialPosts() {
const now = Math.floor(Date.now() / 1000);
await Promise.allSettled(
BOOTSTRAP_WINDOWS_SECONDS.map((windowSeconds) =>
fetchOwnPostsWindow({
since: now - windowSeconds,
limit: BOOTSTRAP_PER_WINDOW_LIMIT,
renderAfterCache: true
})
)
);
if (posts.length < MIN_BOOTSTRAP_POSTS) {
await fetchOwnPostsWindow({
since: 0,
limit: BOOTSTRAP_PER_WINDOW_LIMIT * 2,
renderAfterCache: true
});
}
bufferedLiveEvents.forEach((evt) => {
upsertPost(evt);
});
bufferedLiveEvents.length = 0;
isBootstrapping = false;
renderFeed();
}
async function mountTopComposer() {
if (!divComposer) return;
const hostEl = document.createElement('div');
hostEl.className = 'topPostComposerInput';
divComposer.appendChild(hostEl);
const focusTopComposer = () => {
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);
});
};
let topComposer = null;
topComposer = mountComposer(hostEl, {
currentPubkey,
followedProfiles: [],
showUploadIcon: true,
showPreview: true,
autoHideOnSubmit: false,
onSubmit: async (content) => {
const text = String(content || '').trim();
if (!text) return;
await publishEvent({
kind: 1,
content: text,
tags: [],
created_at: Math.floor(Date.now() / 1000)
});
topComposer?.clear();
focusTopComposer();
}
});
focusTopComposer();
}
async function logout() {
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
cards.destroy();
disconnect();
if (window.NOSTR_LOGIN_LITE?.logout) {
await window.NOSTR_LOGIN_LITE.logout();
}
localStorage.clear();
sessionStorage.clear();
location.reload(true);
}
async function updateFooter() {
try {
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
} catch (_) {}
}
(async function main() {
try {
const versionInfo = await getVersion();
document.getElementById('versionDisplay').textContent = versionInfo.VERSION;
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);
btnSeeMore.addEventListener('click', () => {
displayCount += SEE_MORE_INCREMENT;
renderFeed();
});
await initNDKPage();
// Live broadcast progress display in the footer center section.
// The worker streams per-relay publish results via this window event
// (see plans/broadcast-relays-feature.md §8).
const spanBroadcastStatus = document.getElementById('spanBroadcastStatus');
window.addEventListener('ndkBroadcastProgress', (event) => {
const d = event.detail;
if (!d || !spanBroadcastStatus) return;
if (d.phase === 'start') {
spanBroadcastStatus.textContent = `Broadcasting to ${d.total} relays…`;
} else if (d.phase === 'progress') {
const shortRelay = d.latestRelay
? d.latestRelay.replace(/^wss?:\/\//, '').replace(/\/.*$/, '').slice(0, 30)
: '';
const batchInfo = (d.batchTotal && d.batchTotal > 0)
? ` · ${d.batchCurrent}/${d.batchTotal} batches`
: '';
const relayInfo = shortRelay ? ` · ${shortRelay}` : '';
spanBroadcastStatus.textContent = `📡 ${d.successful}/${d.total} relays${batchInfo}${relayInfo}`;
} else if (d.phase === 'done') {
// Keep the result displayed until the next publish overwrites it.
spanBroadcastStatus.textContent = `${d.successful}/${d.total} relays` + (d.failed > 0 ? ` (${d.failed} failed)` : '');
}
});
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);
await ensureMuteListLoaded();
if (isEventMode) {
targetEventId = resolveEventParamToId(eventParamRaw);
if (!targetEventId) {
throw new Error(`Invalid event parameter: ${eventParamRaw}`);
}
targetProfilePubkey = currentPubkey;
} else {
targetProfilePubkey = isProfileMode
? (
resolveProfileParamToPubkey(profileParamRaw)
|| resolveProfileParamToPubkey(npubParamRaw)
|| resolveProfileParamToPubkey(pubkeyParamRaw)
)
: currentPubkey;
if (isProfileMode && !targetProfilePubkey) {
throw new Error(`Invalid profile parameter: ${profileParamRaw}`);
}
}
applyPageModeUI();
await renderProfileCard();
if (!isProfileMode && !isEventMode) {
await mountTopComposer();
}
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
await Promise.all([
updateFooterRelayStatus(),
updateSidenavRelaySection()
]);
void updateBlossomSection().catch(() => {});
updateIntervalId = setInterval(updateFooter, 5000);
window.addEventListener('ndkRelayActivity', (e) => {
setRelayActivityState(e.detail.relayUrl, e.detail.activity);
});
window.addEventListener('ndkEvent', (e) => {
const evt = e.detail;
if (!evt) return;
if (evt.kind !== 1) return;
if (isEventMode) {
if (!targetEventId || evt.id !== targetEventId) return;
if (postIds.has(evt.id)) return;
renderSingleEvent(evt);
return;
}
if (evt.pubkey !== targetProfilePubkey) return;
if (isBootstrapping) {
bufferedLiveEvents.push(evt);
return;
}
if (!upsertPost(evt)) return;
if (renderedPostIds.size > 0 && divFeed.children.length > 0) {
prependPostCard(evt);
} else {
renderFeed();
}
});
if (isEventMode) {
let matched = null;
try {
const cachedEvent = await queryCache({ ids: [targetEventId], limit: 1 });
matched = Array.isArray(cachedEvent)
? cachedEvent.find((evt) => evt?.id === targetEventId) || cachedEvent[0]
: null;
} catch (_) {}
if (matched) {
renderSingleEvent(matched);
} else {
divFeed.innerHTML = '';
divHint.textContent = 'Loading event…';
divFooterRight.textContent = '0 events';
}
void ndkFetchEvents({ ids: [targetEventId], limit: 1 }).then((relayEvents) => {
const relayMatch = Array.isArray(relayEvents)
? relayEvents.find((evt) => evt?.id === targetEventId) || relayEvents[0]
: null;
if (relayMatch) {
renderSingleEvent(relayMatch);
return;
}
if (!matched) {
divFeed.innerHTML = '';
divHint.textContent = 'Event not found yet.';
divFooterRight.textContent = '0 events';
}
}).catch((error) => {
console.warn('[post.html] Event relay hydration failed:', error?.message || error);
if (!matched) {
divHint.textContent = 'Event not found yet.';
}
});
const liveSince = Math.floor(Date.now() / 1000) - (30 * 24 * 3600);
subscribe({ kinds: [1], ids: [targetEventId], since: liveSince }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
} else {
const liveSince = Math.floor(Date.now() / 1000) - (24 * 3600);
subscribe({ kinds: [1], authors: [targetProfilePubkey], since: liveSince }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
await bootstrapInitialPosts();
}
} catch (error) {
console.error('[post.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>`;
}
})();
</script>
</body>
</html>