import { initNDKPage, getPubkey, subscribe, publishEvent, disconnect, fetchEventsFromAllRelays, queryCache, fetchCachedProfile, storeProfile, getUserSettings, patchUserSettings, onUserSettings } from './js/init-ndk.mjs'; import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs"; import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs'; // htmlFormatText is now imported directly by post-interactions.mjs import { initInteractions, formatTimeAgo, registerTimeAgo } from './js/post-interactions.mjs'; // URL Parameter Parsing const urlParams = new URLSearchParams(window.location.search); const npubParam = urlParams.get('npub'); // hex or bech32 const profileParam = urlParams.get('profile'); // 'true' or 'false' const postParam = urlParams.get('post'); // 'true' or 'false' const followsParam = urlParams.get('follows'); // 'true' or 'false' const viewedParam = urlParams.get('viewed'); // 'true' or 'false' const showViewed = viewedParam === 'true'; const showProfile = profileParam === 'true'; // viewed=true implies post=false and follows=true; npub is used only as selected-follow filter const showPostBox = showViewed ? false : (postParam !== 'false'); const showFollows = showViewed ? true : (followsParam === 'true'); // Helper function to convert npub to hex function npubToHex(npub) { if (!npub) return null; if (npub.startsWith('npub')) { try { const decoded = window.NostrTools.nip19.decode(npub); return decoded.data; } catch (error) { console.error('[post.html] Failed to decode npub:', error); return null; } } // Assume it's already hex return npub; } // Helper function to render read-only profile card async function renderProfileCard(pubkey, profileEvent) { const divProfile = document.getElementById('divProfile'); if (!divProfile || !profileEvent) return; let profile; try { profile = JSON.parse(profileEvent.content); } catch (error) { console.error('[post.html] Failed to parse profile:', error); return; } const displayName = profile.display_name || profile.name || pubkey.substring(0, 8) + '…'; const nip05 = profile.nip05 || ''; const lud16 = profile.lud16 || ''; const website = profile.website || ''; const about = profile.about || ''; let html = ''; // Banner if (profile.banner) { html += `
`; } // Profile info section html += `
`; // Avatar and name row html += `
`; if (profile.picture) { html += ``; } html += `
`; html += `
${displayName}
`; if (profile.name && profile.name !== displayName) { html += `
@${profile.name}
`; } if (nip05) { html += `
${nip05}
`; } html += `
`; // About if (about) { html += `
${about}
`; } // Links html += ``; html += `
`; divProfile.innerHTML = html; divProfile.style.display = 'block'; // Update page title with user's name if (profile.display_name || profile.name) { document.title = displayName; const headerText = document.querySelector('.divHeaderText'); if (headerText) { headerText.textContent = displayName.toUpperCase(); } } } // Debounce utility for renderFeed let renderFeedTimeout = null; let pendingRender = false; function debouncedRenderFeed() { pendingRender = true; if (renderFeedTimeout) { clearTimeout(renderFeedTimeout); } renderFeedTimeout = setTimeout(() => { pendingRender = false; renderFeed(); }, 50); // 50ms debounce } // ── Viewed mode helpers ──────────────────────────────────────────────── /** * Returns true if the event is an original post (not a reply). * A reply has 'e' tags that are NOT of type 'mention'. */ 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'); } /** * Returns the viewed-since timestamp for a given pubkey. * Falls back to 24 hours ago for new follows. */ function getViewedTimestamp(pubkey) { if (viewedData && viewedData.follows && viewedData.follows[pubkey]) { return viewedData.follows[pubkey]; } return Math.floor(Date.now() / 1000) - 86400; } /** * Returns true if the event is unread for its author. */ function isUnread(event) { return event.created_at > getViewedTimestamp(event.pubkey); } /** * Returns the unread count for a given pubkey. */ function getUnreadCount(pubkey) { return unreadPosts.filter(p => p.pubkey === pubkey).length; } /** * Renders the left-column follows list with unread counts. * Called after contact list and viewed data are both available, * and re-called whenever unread counts change. */ async function renderFollowsList() { const divFollowsList = document.getElementById('divFollowsList'); if (!divFollowsList) return; // Build display info for each follow const followItems = await Promise.all( followedPubkeys.map(async (pk) => { let profile = null; if (interactions) { try { profile = await interactions.prewarmProfileCache([pk]).then(() => null).catch(() => null); } catch (_) {} // Use fetchCachedProfile directly via interactions module } // Try to get name from fetchCachedProfile let name = pk.substring(0, 8) + '…'; try { const cached = await fetchCachedProfile(pk); if (cached) { name = cached.display_name || cached.name || name; } } catch (_) {} return { pk, name, unread: getUnreadCount(pk) }; }) ); // Sort: unread group (alpha) then caught-up group (alpha) const withUnread = followItems.filter(f => f.unread > 0).sort((a, b) => a.name.localeCompare(b.name)); const caughtUp = followItems.filter(f => f.unread === 0).sort((a, b) => a.name.localeCompare(b.name)); divFollowsList.innerHTML = ''; const renderItem = (f) => { const div = document.createElement('div'); div.className = 'follow-item' + (f.unread > 0 ? ' has-unread' : ' caught-up') + (selectedFollowPubkey === f.pk ? ' active' : ''); div.dataset.pubkey = f.pk; const nameEl = document.createElement('span'); nameEl.className = 'follow-name'; nameEl.textContent = f.name; div.appendChild(nameEl); if (f.unread > 0) { const unreadBadge = document.createElement('span'); unreadBadge.className = 'follow-unread-badge'; unreadBadge.textContent = String(f.unread); unreadBadge.title = 'Select follow, then click again to mark all as read'; unreadBadge.addEventListener('click', async (ev) => { ev.stopPropagation(); if (selectedFollowPubkey !== f.pk) { selectFollow(f.pk); return; } await markFollowAsRead(f.pk); }); div.appendChild(unreadBadge); } div.addEventListener('click', () => selectFollow(f.pk)); divFollowsList.appendChild(div); }; withUnread.forEach(renderItem); if (withUnread.length > 0 && caughtUp.length > 0) { const hr = document.createElement('hr'); hr.className = 'follows-divider'; divFollowsList.appendChild(hr); } caughtUp.forEach(renderItem); } /** * Renders only the unread posts into divFeed, filtered by selectedFollowPubkey. */ function renderViewedFeed() { const divFeed = document.getElementById('divFeed'); if (!divFeed) return; // In viewed mode, selecting a follow should show only that follow's unread posts. // With no selection, show all cached posts and style read/unread via unreadPosts membership. const toShow = selectedFollowPubkey ? unreadPosts.filter(p => p.pubkey === selectedFollowPubkey) : posts; const unreadIds = new Set(unreadPosts.map(p => p.id)); // Sort newest first toShow.sort((a, b) => b.created_at - a.created_at); // Full rerender so read/unread styles always match current state. divFeed.innerHTML = ''; renderedPostIds.clear(); toShow.forEach(post => { const postEl = createPostElement(post); postEl.dataset.postId = post.id; postEl.dataset.pubkey = post.pubkey; postEl.dataset.createdAt = String(post.created_at || 0); postEl.classList.add(unreadIds.has(post.id) ? 'is-unread' : 'is-read'); divFeed.appendChild(postEl); renderedPostIds.add(post.id); }); // Show/hide mark-read button const btnMarkRead = document.getElementById('btnMarkRead'); if (btnMarkRead) { btnMarkRead.style.display = toShow.length > 0 ? 'block' : 'none'; } } function hexToNpub(hexPubkey) { if (!hexPubkey) return null; try { return window.NostrTools.nip19.npubEncode(hexPubkey); } catch (_) { return null; } } function updateViewedHeaderAndUrl(selectedPubkey = null) { const headerText = document.querySelector('.divHeaderText'); const baseTitle = 'VIEWED'; if (!showViewed) return; if (selectedPubkey) { const activeEl = document.querySelector(`.follow-item[data-pubkey="${selectedPubkey}"] .follow-name`); const selectedName = activeEl?.textContent?.trim() || selectedPubkey.substring(0, 8) + '…'; document.title = selectedName; if (headerText) headerText.textContent = selectedName.toUpperCase(); } else { document.title = baseTitle; if (headerText) headerText.textContent = baseTitle; } const url = new URL(window.location.href); url.searchParams.set('viewed', 'true'); if (selectedPubkey) { const selectedNpub = hexToNpub(selectedPubkey); if (selectedNpub) { url.searchParams.set('npub', selectedNpub); } else { url.searchParams.set('npub', selectedPubkey); } } else { url.searchParams.delete('npub'); } window.history.replaceState({}, '', url.toString()); } /** * Selects a follow, filtering the right column to that author. */ function selectFollow(pubkey) { if (selectedFollowPubkey === pubkey) { // Toggle off — show all clearFollowSelection(); return; } selectedFollowPubkey = pubkey; // Update active highlight document.querySelectorAll('.follow-item').forEach(el => { el.classList.toggle('active', el.dataset.pubkey === pubkey); }); updateViewedHeaderAndUrl(pubkey); // Re-render feed filtered to this author const divFeed = document.getElementById('divFeed'); if (divFeed) { divFeed.innerHTML = ''; renderedPostIds.clear(); } renderViewedFeed(); } /** * Clears the follow selection, showing all unread posts. */ function clearFollowSelection() { selectedFollowPubkey = null; document.querySelectorAll('.follow-item').forEach(el => el.classList.remove('active')); updateViewedHeaderAndUrl(null); const divFeed = document.getElementById('divFeed'); if (divFeed) { divFeed.innerHTML = ''; renderedPostIds.clear(); } renderViewedFeed(); } /** * Returns the next follow in the currently rendered sidebar order. */ function getNextFollowPubkey(currentPubkey) { const order = Array.from(document.querySelectorAll('#divFollowsList .follow-item')) .map(el => el.dataset.pubkey) .filter(Boolean); const index = order.indexOf(currentPubkey); if (index < 0 || index >= order.length - 1) return null; return order[index + 1]; } /** * Encrypts and publishes the current viewedData as kind 30078. */ async function publishViewedData(now) { const jsonStr = JSON.stringify(viewedData); const encrypted = await window.nostr.nip44.encrypt(currentPubkey, jsonStr); const publishResult = await publishEvent({ kind: 30078, tags: [['d', 'viewed']], content: encrypted, created_at: now }); const successful = publishResult?.relayResults?.successful || []; const failed = publishResult?.relayResults?.failed || []; console.log('[post.html] viewed publish relay results:', { successfulCount: successful.length, failedCount: failed.length, successful, failed }); // Keep relay status UI fresh after writes. try { await updateFooterRelayStatus(); await updateSidenavRelaySection(); } catch (_) {} return publishResult; } function recalculateLastGlobalView(fallbackTs = Math.floor(Date.now() / 1000)) { const timestamps = Object.values(viewedData?.follows || {}); viewedData.lastGlobalView = timestamps.length > 0 ? Math.min(...timestamps) : fallbackTs; } function applyViewedReadStateLocally() { renderViewedFeed(); renderFollowsList(); } function scheduleViewedDataPublish(delayMs = 500) { if (!showViewed || !viewedData) return; if (viewedPublishTimeout) clearTimeout(viewedPublishTimeout); viewedPublishTimeout = setTimeout(async () => { viewedPublishTimeout = null; try { await publishViewedData(Math.floor(Date.now() / 1000)); console.log('[post.html] Published batched viewed updates'); } catch (err) { console.warn('[post.html] Failed to publish batched viewed updates:', err); } }, delayMs); } function markShownPostsAsReadByScroll(postElements) { if (!viewedData || postElements.length === 0) return; const idsToMark = new Set(postElements.map(el => el.dataset.postId).filter(Boolean)); if (idsToMark.size === 0) return; let changed = false; // Update per-follow persisted timestamps based only on crossed posts. for (const el of postElements) { const pubkey = el.dataset.pubkey; const createdAt = Number(el.dataset.createdAt || 0); if (!pubkey || !createdAt) continue; const prev = viewedData.follows?.[pubkey] || 0; if (createdAt > prev) { viewedData.follows[pubkey] = createdAt; changed = true; } } // Immediate UI state: remove only crossed posts from unread list. const beforeCount = unreadPosts.length; unreadPosts = unreadPosts.filter(evt => !idsToMark.has(evt.id)); if (unreadPosts.length !== beforeCount) changed = true; if (!changed) return; recalculateLastGlobalView(); applyViewedReadStateLocally(); scheduleViewedDataPublish(350); } function handleViewedScrollMarking() { if (!showViewed || !scrollToMarkEnabled || !viewedData) return; const feedColumn = document.getElementById('divFeedColumn'); if (!feedColumn) return; const allItems = Array.from(document.querySelectorAll('#divFeed .divPostItem')); if (allItems.length === 0) return; // Mark item as read once its midpoint is above the top edge of the feed viewport. const topBoundary = feedColumn.getBoundingClientRect().top; const midpointPassed = allItems.filter(el => { const rect = el.getBoundingClientRect(); return (rect.top + rect.height / 2) < topBoundary; }); if (midpointPassed.length > 0) { markShownPostsAsReadByScroll(midpointPassed); } // If user reaches bottom of the posts column, mark all currently shown posts as read. const nearBottom = feedColumn.scrollTop + feedColumn.clientHeight >= feedColumn.scrollHeight - 5; if (nearBottom && scrollBottomMarkArmed) { scrollBottomMarkArmed = false; markShownPostsAsReadByScroll(allItems); } else if (!nearBottom) { scrollBottomMarkArmed = true; } } function applyUserSettings(settings) { const nextScrollToMark = !!settings?.post?.viewed?.scrollToMark; scrollToMarkEnabled = nextScrollToMark; const scrollCheckbox = document.getElementById('chkScrollToMark'); if (scrollCheckbox) { scrollCheckbox.checked = nextScrollToMark; } if (!scrollToMarkEnabled) { scrollBottomMarkArmed = true; } } async function persistScrollToMarkSetting(enabled) { try { await patchUserSettings({ post: { viewed: { scrollToMark: !!enabled } } }); } catch (err) { console.warn('[post.html] Failed to persist scrollToMark setting:', err.message || err); } } function initViewedSideNavControls() { if (!showViewed || !divSideNavBody) return; if (document.getElementById('divViewedControls')) return; const controls = document.createElement('div'); controls.id = 'divViewedControls'; const btnMarkAllRead = document.createElement('button'); btnMarkAllRead.id = 'btnMarkAllRead'; btnMarkAllRead.className = 'viewed-sidenav-control'; btnMarkAllRead.textContent = 'Mark all as read'; btnMarkAllRead.addEventListener('click', markEverythingAsRead); const btnUnmarkAll = document.createElement('button'); btnUnmarkAll.id = 'btnUnmarkAll'; btnUnmarkAll.className = 'viewed-sidenav-control'; btnUnmarkAll.textContent = 'Unmark all as read'; btnUnmarkAll.addEventListener('click', unmarkAllAsRead); const scrollLabel = document.createElement('label'); scrollLabel.className = 'viewed-sidenav-toggle'; const scrollCheckbox = document.createElement('input'); scrollCheckbox.type = 'checkbox'; scrollCheckbox.id = 'chkScrollToMark'; scrollCheckbox.checked = scrollToMarkEnabled; const scrollText = document.createElement('span'); scrollText.textContent = 'Scroll to mark'; scrollLabel.appendChild(scrollCheckbox); scrollLabel.appendChild(scrollText); scrollCheckbox.addEventListener('change', () => { scrollToMarkEnabled = scrollCheckbox.checked; if (!scrollToMarkEnabled) { scrollBottomMarkArmed = true; } else { handleViewedScrollMarking(); } persistScrollToMarkSetting(scrollToMarkEnabled); }); controls.appendChild(btnMarkAllRead); controls.appendChild(btnUnmarkAll); controls.appendChild(scrollLabel); divSideNavBody.insertBefore(controls, divSideNavBody.firstChild); } /** * Unmarks all follows as read for testing by resetting viewed timestamps, * then repopulates unread posts from relays. */ async function unmarkAllAsRead() { if (!viewedData) return; closeNav(); const now = Math.floor(Date.now() / 1000); const resetTs = now - 86400; for (const pk of feedPubkeys) { viewedData.follows[pk] = resetTs; } viewedData.lastGlobalView = resetTs; try { await publishViewedData(now); console.log('[post.html] Published reset kind 30078 viewed event'); } catch (err) { console.error('[post.html] Failed to publish reset kind 30078:', err); } posts = []; unreadPosts = []; selectedFollowPubkey = null; updateViewedHeaderAndUrl(null); try { const fetched = await fetchEventsFromAllRelays({ kinds: [1], authors: Array.from(feedPubkeys), since: resetTs, limit: 500 }); const dedup = new Map(); Object.values(fetched).flat().forEach(evt => { if (evt?.id && !dedup.has(evt.id)) dedup.set(evt.id, evt); }); const rebuiltPosts = Array.from(dedup.values()) .filter(evt => feedPubkeys.has(evt.pubkey)) .filter(evt => isOriginalPost(evt)); posts = rebuiltPosts.slice(); unreadPosts = rebuiltPosts.filter(evt => isUnread(evt)); } catch (err) { console.warn('[post.html] Failed to rebuild unread posts after unmark all:', err); } renderViewedFeed(); await renderFollowsList(); } /** * Marks everything as read by setting all follow timestamps to now. */ async function markEverythingAsRead() { if (!viewedData) return; closeNav(); const now = Math.floor(Date.now() / 1000); for (const pk of feedPubkeys) { viewedData.follows[pk] = now; } viewedData.lastGlobalView = now; unreadPosts = []; selectedFollowPubkey = null; updateViewedHeaderAndUrl(null); try { await publishViewedData(now); console.log('[post.html] Published mark-everything-as-read viewed event'); } catch (err) { console.error('[post.html] Failed to publish mark-everything-as-read:', err); } renderViewedFeed(); await renderFollowsList(); } /** * Marks all unread posts for a single follow as read and publishes viewed state. */ async function markFollowAsRead(pubkey) { if (!viewedData || !pubkey) return; const now = Math.floor(Date.now() / 1000); viewedData.follows[pubkey] = now; unreadPosts = unreadPosts.filter(p => p.pubkey !== pubkey); recalculateLastGlobalView(now); try { await publishViewedData(now); console.log('[post.html] Published updated viewed data for follow:', pubkey.substring(0, 8) + '…'); } catch (err) { console.error('[post.html] Failed to publish follow read state:', err); } renderViewedFeed(); await renderFollowsList(); } /** * Marks all posts as read for the selected follow (or all follows), * encrypts and publishes the updated kind 30078 event. */ async function markAllAsRead() { if (!viewedData) return; const now = Math.floor(Date.now() / 1000); const nextFollowPubkey = selectedFollowPubkey ? getNextFollowPubkey(selectedFollowPubkey) : null; if (selectedFollowPubkey) { viewedData.follows[selectedFollowPubkey] = now; // Remove those posts from unreadPosts unreadPosts = unreadPosts.filter(p => p.pubkey !== selectedFollowPubkey); } else { for (const pk of feedPubkeys) { viewedData.follows[pk] = now; } unreadPosts = []; } // Recalculate lastGlobalView = min of all per-follow timestamps const timestamps = Object.values(viewedData.follows); viewedData.lastGlobalView = timestamps.length > 0 ? Math.min(...timestamps) : now; // Encrypt and publish kind 30078 try { await publishViewedData(now); console.log('[post.html] Published updated kind 30078 viewed event'); } catch (err) { console.error('[post.html] Failed to encrypt/publish kind 30078:', err); } // Update UI const divFeed = document.getElementById('divFeed'); if (divFeed) { divFeed.innerHTML = ''; renderedPostIds.clear(); } if (selectedFollowPubkey) { selectedFollowPubkey = null; } renderViewedFeed(); // Re-render follows list with updated counts await renderFollowsList(); // After marking a selected author as read, move to next author in sidebar order if (nextFollowPubkey) { selectFollow(nextFollowPubkey); } } /** * Called after EOSE on the main viewed subscription. * For follows with no viewed history and fewer than 10 unread posts, * fetches up to 10 more posts without a since filter. */ async function handleViewedEose() { if (viewedInitialEoseFired) return; viewedInitialEoseFired = true; console.log('[post.html] Viewed mode EOSE — checking 10-post minimum for new follows'); const newFollows = followedPubkeys.filter(pk => !viewedData?.follows?.[pk]); for (const pubkey of newFollows) { const count = unreadPosts.filter(p => p.pubkey === pubkey).length; if (count < 10) { try { const moreEvents = await fetchEventsFromAllRelays({ kinds: [1], authors: [pubkey], limit: 10 }); const allMore = Object.values(moreEvents).flat(); for (const evt of allMore) { if (!isOriginalPost(evt)) continue; if (!posts.some(p => p.id === evt.id)) posts.push(evt); if (isUnread(evt) && !unreadPosts.some(p => p.id === evt.id)) unreadPosts.push(evt); } } catch (err) { console.warn('[post.html] 10-post minimum fetch failed for', pubkey.substring(0, 8), err); } } } // Re-render with any newly added posts const divFeed = document.getElementById('divFeed'); if (divFeed) { divFeed.innerHTML = ''; renderedPostIds.clear(); } renderViewedFeed(); renderFollowsList(); } let viewedPostsSubscriptionStarted = false; /** * Starts the single subscription for all follows posts since lastGlobalView. * Called once both viewedData and the contact list are ready. * Guards against double-invocation. */ function startViewedPostsSubscription() { if (viewedPostsSubscriptionStarted) return; if (!viewedData || feedPubkeys.size === 0) return; viewedPostsSubscriptionStarted = true; const since = viewedData.lastGlobalView || (Math.floor(Date.now() / 1000) - 86400); console.log('[post.html] Starting viewed posts subscription, since:', since, 'authors:', feedPubkeys.size); subscribe( { kinds: [1], authors: Array.from(feedPubkeys), since: since, limit: 500 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' } ); } // ── End viewed mode helpers ──────────────────────────────────────────── // Subscription tracking to prevent duplicates const activeSubscriptions = new Map(); // postId -> { unsubscribe, timestamp } const SUBSCRIPTION_EXPIRY_MS = 30000; // 30 seconds before allowing re-subscription function trackSubscription(postIds, subscribeFn, onUpdate) { const key = postIds.sort().join(','); const now = Date.now(); // Check if we already have an active subscription for these posts for (const [existingKey, sub] of activeSubscriptions.entries()) { if (existingKey === key) { if (now - sub.timestamp < SUBSCRIPTION_EXPIRY_MS) { console.log('[post.html] Subscription already active for these posts, skipping'); return { unsubscribe: sub.unsubscribe, isDuplicate: true }; } else { // Expired, clean up old subscription console.log('[post.html] Cleaning up expired subscription'); sub.unsubscribe(); activeSubscriptions.delete(existingKey); } } } // Create new subscription const unsubscribe = subscribeFn(onUpdate); activeSubscriptions.set(key, { unsubscribe, timestamp: now }); return { unsubscribe, isDuplicate: false }; } function cleanupAllSubscriptions() { console.log('[post.html] Cleaning up all interaction subscriptions'); for (const [key, sub] of activeSubscriptions.entries()) { sub.unsubscribe(); } activeSubscriptions.clear(); } // Fetch version from JSON const versionResponse = await fetch('./js/version.json'); const versionData = await versionResponse.json(); const VERSION = versionData.VERSION; console.log(`[post.html ${VERSION}] Loading...`); let updateIntervalId = null; let currentPubkey = null; let hamburgerInstance = null; let isNavOpen = false; let logoutHamburger = null; let themeToggleHamburger = null; let isDarkMode = false; let posts = []; let pendingEvents = new Set(); // Track events we just published let displayCount = 20; // Number of posts to display initially let renderedPostIds = new Set(); // Track which posts are already rendered let newestRenderedTimestamp = 0; // Track the newest post timestamp for proper ordering let isFetchingOlder = false; // Prevent duplicate fetches let targetPubkey = null; // The pubkey whose posts we're displaying let feedPubkeys = new Set(); // Set of pubkeys to show in feed let followedPubkeys = []; // List of pubkeys the target user follows let profileEvent = null; // Kind 0 event for profile display // ── Viewed mode state ────────────────────────────────────────────────── let viewedData = null; // Decrypted kind 30078 payload let unreadPosts = []; // Posts that are unread (filtered from all posts) let selectedFollowPubkey = null; // Currently selected follow in left column let viewedDataLoaded = false; // Whether kind 30078 has been fetched/defaulted let contactListLoaded = false; // Whether kind 3 has been processed let viewedInitialEoseFired = false; // Whether the main posts subscription EOSE fired let scrollToMarkEnabled = false; // Whether scroll-based marking is enabled let scrollBottomMarkArmed = true; // One-shot guard for bottom-of-page marking let viewedPublishTimeout = null; // Debounced publisher for scroll marking updates let unsubscribeUserSettings = null; // Listener cleanup for user settings updates const divBody = document.getElementById("divBody"); const divSideNav = document.getElementById("divSideNav"); const divSideNavBody = document.getElementById("divSideNavBody"); const divFooterCenter = document.getElementById("divFooterCenter"); const divFooterRight = document.getElementById("divFooterRight"); const divPost = document.getElementById("divPost"); const divFeed = document.getElementById("divFeed"); 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 = "50vw"; isNavOpen = true; if (hamburgerInstance) hamburgerInstance.animateTo('arrow_left'); // Comments toggle button is now in the sidebar body 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(); } // Use formatTimeAgo from post-interactions module for real-time updates // Store interaction module instance let interactions = null; function createPostElement(post) { if (!interactions) { // Fallback if interactions not initialized yet const postEl = document.createElement('div'); postEl.className = 'divPostItem'; postEl.textContent = post.content; return postEl; } // Use the unified renderPostItem from post-interactions.mjs // Show author header for all posts (including own posts) return interactions.renderPostItem(post, { currentPubkey, showHeader: true, isCompact: false }); } async function renderFeed() { console.log('[post.html] 🖥️ RENDERING FEED - posts count:', posts.length, 'displayCount:', displayCount); // Sort by created_at descending posts.sort((a, b) => b.created_at - a.created_at); // Show posts up to displayCount const recentPosts = posts.slice(0, displayCount); console.log('[post.html] 🖥️ Displaying', recentPosts.length, 'posts on screen'); // Append-only: only render posts that haven't been rendered yet recentPosts.forEach(post => { if (!renderedPostIds.has(post.id)) { const postEl = createPostElement(post); if (post._pending || post.created_at > newestRenderedTimestamp) { divFeed.prepend(postEl); newestRenderedTimestamp = Math.max(newestRenderedTimestamp, post.created_at); } else { divFeed.appendChild(postEl); } renderedPostIds.add(post.id); } }); // Update See More button visibility const btnSeeMore = document.getElementById('btnSeeMore'); if (btnSeeMore) { // Show button if there are more posts in memory to display // Hide when we've displayed all posts we have (user can click to fetch more) const hasMoreToShow = posts.length > displayCount; btnSeeMore.style.display = hasMoreToShow ? 'block' : 'none'; } // Fetch existing interactions and subscribe for new ones (fire-and-forget, don't block render) if (interactions && recentPosts.length > 0) { const postIds = recentPosts.map(p => p.id); // Fetch existing interactions without awaiting — let posts render instantly interactions.fetchExistingInteractions(postIds, { currentPubkey, onUpdate: (postId, state, event) => { // console.log('[post.html] Fetched existing interaction for', postId); // Update the interaction bar UI interactions.updateInteractionBar(postId, state); // Handle comments if (event && event.kind === 1) { const postEl = divFeed.querySelector(`.divPostItem[data-post-id="${postId}"]`); if (postEl) { const existingThread = postEl.querySelector('.divCommentThread'); if (!existingThread) { // First comment - render the thread const commentThread = interactions.renderCommentThread(postId, { currentPubkey }); postEl.appendChild(commentThread); } else { // Thread exists - add this specific comment if not already present const existingComment = existingThread.querySelector(`[data-comment-id="${event.id}"]`); if (!existingComment) { interactions.addCommentToPost(postId, { id: event.id, pubkey: event.pubkey, content: event.content, created_at: event.created_at, tags: event.tags }, currentPubkey); } } } } } }); // Then subscribe for new interactions in real-time (with duplicate prevention) trackSubscription( postIds, (onUpdate) => interactions.subscribeToInteractions(postIds, { currentPubkey, onUpdate }), (postId, state, event) => { // console.log('[post.html] New interaction for', postId, state); // Update the interaction bar UI interactions.updateInteractionBar(postId, state); // If it's a new comment, add it to the DOM if (event && event.kind === 1) { interactions.addCommentToPost(postId, { id: event.id, pubkey: event.pubkey, content: event.content, created_at: event.created_at, tags: event.tags }, currentPubkey); } } ); } } async function handlePost() { const content = divPost.innerText.trim(); if (!content) return; const now = Math.floor(Date.now() / 1000); const event = { kind: 1, created_at: now, tags: [], content: content }; try { console.log('[post.html] 📤 Calling publishEvent:', event); const result = await publishEvent(event); console.log('[post.html] ✅ publishEvent returned:', result); // Track this event - it will be added when we receive it from relay if (result?.event?.id) { pendingEvents.add(result.event.id); console.log('[post.html] 📋 Added to pendingEvents:', result.event.id); // Immediately render the pending post for better UX // Create a temporary event object with the result ID const pendingPost = { id: result.event.id, kind: 1, pubkey: currentPubkey, content: content, created_at: now, tags: [], _pending: true // Mark as pending }; // Add to posts array and render immediately if (!posts.some(p => p.id === pendingPost.id)) { posts.push(pendingPost); debouncedRenderFeed(); console.log('[post.html] 🖥️ Pending post rendered immediately'); } } else { console.log('[post.html] ⚠️ No event ID in result:', result); } // Clear the input console.log('[post.html] 🧹 Clearing post box'); divPost.innerHTML = ''; divPost.innerText = ''; } catch (error) { console.error('[post.html] ❌ Publish failed:', error); } } function handleKeyDown(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handlePost(); } } const UpdateFooter = async () => { try { await updateFooterRelayStatus(); await updateSidenavRelaySection(); divFooterRight.innerHTML = `${posts.length} posts`; } catch (error) { console.error('[post.html] Error updating footer:', error); } }; const Logout = async () => { if (updateIntervalId) { clearInterval(updateIntervalId); updateIntervalId = null; } disconnect(); if (window.NOSTR_LOGIN_LITE?.logout) { await window.NOSTR_LOGIN_LITE.logout(); } localStorage.clear(); sessionStorage.clear(); location.reload(true); }; (async function main() { try { initHamburgerMenu(); document.getElementById('divSvgHam').addEventListener('click', toggleNav); // Conditionally attach post box keydown listener if (showPostBox) { divPost.addEventListener('keydown', handleKeyDown); } else { divPost.style.display = 'none'; } document.getElementById('themeToggleButton')?.addEventListener('click', () => { isDarkMode = !isDarkMode; localStorage.setItem('theme', isDarkMode ? 'dark' : 'light'); localStorage.setItem('sidenavWasOpen', isNavOpen ? 'true' : 'false'); window.location.reload(); }); document.getElementById('logoutButton')?.addEventListener('click', Logout); await initNDKPage(); currentPubkey = await getPubkey(); console.log('[post.html] Authenticated as:', currentPubkey); // Determine target pubkey from URL param or default to current user // In viewed mode, npub is selection state only and target must remain current user. let initialViewedSelectionPubkey = null; if (showViewed) { targetPubkey = currentPubkey; if (npubParam) { const parsedSelection = npubToHex(npubParam); if (parsedSelection) { initialViewedSelectionPubkey = parsedSelection; console.log('[post.html] Viewed mode initial selected follow from URL:', initialViewedSelectionPubkey); } } } else if (npubParam) { const parsedPubkey = npubToHex(npubParam); if (parsedPubkey) { targetPubkey = parsedPubkey; console.log('[post.html] Target pubkey from URL:', targetPubkey); } else { console.warn('[post.html] Invalid npub parameter, using current user'); targetPubkey = currentPubkey; } } else { targetPubkey = currentPubkey; } // Update page title and header based on mode const headerText = document.querySelector('.divHeaderText'); if (showViewed) { document.title = 'VIEWED'; if (headerText) headerText.textContent = 'VIEWED'; } else if (showFollows) { document.title = 'FEED'; if (headerText) headerText.textContent = 'FEED'; } else if (targetPubkey !== currentPubkey) { document.title = 'USER'; if (headerText) headerText.textContent = 'USER'; } initFooterRelayStatus(); initSidenavRelaySection(); // Set up event listeners BEFORE creating subscriptions // This ensures we don't miss events that arrive immediately from cache window.addEventListener('ndkEvent', (e) => { const evt = e.detail; // console.log('[post.html] 📥 Received ndkEvent:', { id: evt.id, kind: evt.kind, relayUrl: evt.relayUrl, fromCache: evt.fromCache, pending: pendingEvents.has(evt.id) }); // Handle kind 0 profile events for profile card if (evt.kind === 0 && evt.pubkey === targetPubkey && showProfile && !profileEvent) { profileEvent = evt; renderProfileCard(targetPubkey, profileEvent); } // Handle kind 30078 viewed event (viewed mode only) if (showViewed && evt.kind === 30078 && evt.pubkey === currentPubkey) { const dTag = evt.tags?.find(t => t[0] === 'd' && t[1] === 'viewed'); if (dTag && !viewedDataLoaded) { viewedDataLoaded = true; (async () => { try { const decrypted = await window.nostr.nip44.decrypt(currentPubkey, evt.content); viewedData = JSON.parse(decrypted); console.log('[post.html] Decrypted kind 30078 viewed data, lastGlobalView:', viewedData.lastGlobalView); } catch (err) { console.warn('[post.html] Failed to decrypt kind 30078, using defaults:', err); viewedData = { v: 1, lastGlobalView: Math.floor(Date.now() / 1000) - 86400, follows: {} }; } // Recompute unread state against the actual viewed data in case posts were // already loaded from cache using temporary defaults. if (posts.length > 0) { unreadPosts = posts.filter(p => isUnread(p)); renderViewedFeed(); renderFollowsList(); } // If contact list already loaded, start the posts subscription now if (contactListLoaded && feedPubkeys.size > 0) { startViewedPostsSubscription(); } })(); } } // Handle kind 3 contact list events for follows/viewed mode if (evt.kind === 3 && evt.pubkey === targetPubkey && showFollows) { const pTags = evt.tags?.filter(tag => tag[0] === 'p') || []; const newFollowedPubkeys = pTags.map(tag => tag[1]).filter(pk => pk && !feedPubkeys.has(pk)); if (newFollowedPubkeys.length > 0) { console.log('[post.html] Contact list update: adding', newFollowedPubkeys.length, 'new followed pubkeys'); newFollowedPubkeys.forEach(pk => { feedPubkeys.add(pk); followedPubkeys.push(pk); }); if (showViewed) { // In viewed mode, mark contact list as loaded and start posts sub if viewedData ready contactListLoaded = true; if (viewedDataLoaded && feedPubkeys.size > 0) { startViewedPostsSubscription(); } // Re-render follows list as new follows arrive renderFollowsList(); } else { // Normal follows mode: subscribe to posts from newly discovered follows subscribe( { kinds: [1], authors: newFollowedPubkeys, limit: 50 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' } ); } } } if (evt.kind === 1 && feedPubkeys.has(evt.pubkey)) { // Check if already have this post if (!posts.some(p => p.id === evt.id)) { if (showViewed) { // Viewed mode: keep original posts in view and mark unread separately. if (isOriginalPost(evt)) { posts.push(evt); if (isUnread(evt)) unreadPosts.push(evt); renderFollowsList(); renderViewedFeed(); } } else if (pendingEvents.has(evt.id)) { // Our own just-published post pendingEvents.delete(evt.id); posts.push(evt); debouncedRenderFeed(); } else { // Historical or live post from relay/cache posts.push(evt); debouncedRenderFeed(); } } } // EOSE signal for viewed mode posts subscription if (showViewed && evt._isEose) { handleViewedEose(); } }); window.addEventListener('ndkRelayActivity', (e) => { setRelayActivityState(e.detail.relayUrl, e.detail.activity); }); if (showViewed) { // Non-blocking settings load so viewed mode can render from cache immediately. getUserSettings() .then((initialSettings) => { applyUserSettings(initialSettings); }) .catch((err) => { console.warn('[post.html] Failed to load user settings, using defaults:', err.message || err); }); unsubscribeUserSettings = onUserSettings((settings) => { applyUserSettings(settings); }); } // Build feed pubkeys set // Fire off cache queries immediately (non-blocking), start subscriptions, then render from cache // after interactions is initialized so full post templates are used. let cachePostsPromise = null; if (showViewed) { // ── Viewed mode setup ────────────────────────────────────────── console.log('[post.html] Setting up viewed mode for:', currentPubkey); // Restructure DOM: two-column layout document.body.classList.add('viewed-mode'); const divFollowsList = document.createElement('div'); divFollowsList.id = 'divFollowsList'; const divFeedColumn = document.createElement('div'); divFeedColumn.id = 'divFeedColumn'; // Move divFeed into the right column divFeedColumn.appendChild(divFeed); // Hide btnSeeMore — not used in viewed mode const btnSeeMoreEl = document.getElementById('btnSeeMore'); if (btnSeeMoreEl) btnSeeMoreEl.style.display = 'none'; // Create Mark All As Read button const btnMarkRead = document.createElement('button'); btnMarkRead.id = 'btnMarkRead'; btnMarkRead.textContent = 'Mark All As Read'; btnMarkRead.addEventListener('click', markAllAsRead); divFeedColumn.appendChild(btnMarkRead); // Insert both columns into divBody (clear existing children first) divBody.innerHTML = ''; divBody.appendChild(divFollowsList); divBody.appendChild(divFeedColumn); // Add viewed controls into hamburger sidenav body. initViewedSideNavControls(); // Scroll-based read marking for viewed mode (right column only). divFeedColumn.addEventListener('scroll', handleViewedScrollMarking); // Initialize default viewed data immediately so first render can proceed from cache // without waiting for relay round trips when a viewed event is missing. if (!viewedDataLoaded || !viewedData) { viewedDataLoaded = true; viewedData = { v: 1, lastGlobalView: Math.floor(Date.now() / 1000) - 86400, follows: {} }; console.log('[post.html] Using immediate default viewedData until kind 30078 arrives'); } // Subscribe to kind 30078 viewed event (cached/relay event will override defaults). subscribe( { kinds: [30078], authors: [currentPubkey], '#d': ['viewed'] }, { closeOnEose: true, cacheUsage: 'CACHE_FIRST' } ); // Subscribe to kind 3 contact list subscribe( { kinds: [3], authors: [currentPubkey], limit: 1 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' } ); fetchEventsFromAllRelays({ kinds: [3], authors: [currentPubkey], limit: 1 }); cachePostsPromise = queryCache({ kinds: [3], authors: [currentPubkey], limit: 1 }).catch(err => { console.warn('[post.html] Cache query for contact list failed (non-fatal):', err.message); return []; }); } else if (showFollows) { // Subscribe to kind 3 contact list for target user console.log('[post.html] Setting up follows feed for:', targetPubkey); subscribe( { kinds: [3], authors: [targetPubkey], limit: 1 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' } ); fetchEventsFromAllRelays({ kinds: [3], authors: [targetPubkey], limit: 1 }); // Start contact list cache query immediately (non-blocking) cachePostsPromise = queryCache({ kinds: [3], authors: [targetPubkey], limit: 1 }).catch(err => { console.warn('[post.html] Cache query for contact list failed (non-fatal):', err.message); return []; }); } else { // Single user mode feedPubkeys.add(targetPubkey); subscribe( { kinds: [1], authors: [targetPubkey], limit: 50 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' } ); // Start posts cache query immediately (non-blocking) cachePostsPromise = queryCache({ kinds: [1], authors: [targetPubkey], limit: 50 }).catch(err => { console.warn('[post.html] Cache query for posts failed (non-fatal):', err.message); return []; }); } // Start profile cache lookup immediately (non-blocking) if profile is requested // Use fetchCachedProfile (NDK in-memory/Dexie cache) instead of queryCache (profiles table) // because fetchCachedProfile reliably returns HIT while queryCache for kind 0 can miss. const cacheProfilePromise = showProfile ? fetchCachedProfile(targetPubkey).catch(() => null) : Promise.resolve(null); // Initialize post interactions module — runs while cache queries are in-flight interactions = initInteractions({ subscribe, publishEvent, getPubkey, fetchEventsFromAllRelays, fetchCachedProfile, storeProfile }); console.log('[post.html] Post interactions module initialized'); // Render profile card FIRST (before posts) so it appears immediately from cache if (showProfile) { const cachedProfile = await cacheProfilePromise; if (cachedProfile) { // fetchCachedProfile returns a profile object directly — wrap it for renderProfileCard const syntheticEvent = { pubkey: targetPubkey, kind: 0, created_at: cachedProfile.created_at || 0, content: JSON.stringify(cachedProfile), tags: [] }; profileEvent = syntheticEvent; await renderProfileCard(targetPubkey, profileEvent); console.log('[post.html] Profile card rendered from cache for', targetPubkey.substring(0, 8) + '…'); } // Background relay fetch — renders if newer than cache or cache missed fetchEventsFromAllRelays({ kinds: [0], authors: [targetPubkey], limit: 1 }).then(async eventsByRelay => { const allEvents = Object.values(eventsByRelay).flat(); if (allEvents.length > 0) { const latest = allEvents.sort((a, b) => b.created_at - a.created_at)[0]; if (!profileEvent || latest.created_at > profileEvent.created_at) { profileEvent = latest; await renderProfileCard(targetPubkey, profileEvent); } } }).catch(() => {}); } // Now await cache results and render posts if (showViewed) { // ── Viewed mode: process cached contact list ─────────────────── const cachedContactEvents = await cachePostsPromise; if (cachedContactEvents && cachedContactEvents.length > 0) { console.log('[post.html] Viewed mode: found contact list in cache'); const contactEvent = cachedContactEvents.sort((a, b) => b.created_at - a.created_at)[0]; const pTags = contactEvent.tags?.filter(tag => tag[0] === 'p') || []; followedPubkeys = pTags.map(tag => tag[1]).filter(pk => pk); console.log('[post.html] Viewed mode: found', followedPubkeys.length, 'followed pubkeys from cache'); followedPubkeys.forEach(pk => feedPubkeys.add(pk)); contactListLoaded = true; // Hydrate viewed-mode posts directly from cache so refresh is instant. try { const cachedViewedPosts = await queryCache( { kinds: [1], authors: Array.from(feedPubkeys), limit: 500 } ); if (cachedViewedPosts && cachedViewedPosts.length > 0) { console.log('[post.html] Viewed mode: found', cachedViewedPosts.length, 'cached posts'); cachedViewedPosts.forEach(evt => { if (evt.id && evt.pubkey && isOriginalPost(evt) && !posts.some(p => p.id === evt.id)) { posts.push(evt); } }); unreadPosts = posts.filter(evt => isUnread(evt)); renderViewedFeed(); } } catch (cacheErr) { console.warn('[post.html] Cache query for viewed posts failed (non-fatal):', cacheErr.message); } // Render follows list immediately using current unread state. await renderFollowsList(); if (initialViewedSelectionPubkey && followedPubkeys.includes(initialViewedSelectionPubkey)) { selectFollow(initialViewedSelectionPubkey); initialViewedSelectionPubkey = null; } // If viewedData already loaded, start posts subscription now if (viewedDataLoaded && feedPubkeys.size > 0) { startViewedPostsSubscription(); } } else { console.log('[post.html] Viewed mode: no contact list in cache, waiting for subscription...'); } } else if (showFollows) { const cachedContactEvents = await cachePostsPromise; if (cachedContactEvents && cachedContactEvents.length > 0) { console.log('[post.html] Found contact list in cache'); const contactEvent = cachedContactEvents.sort((a, b) => b.created_at - a.created_at)[0]; const pTags = contactEvent.tags?.filter(tag => tag[0] === 'p') || []; followedPubkeys = pTags.map(tag => tag[1]).filter(pk => pk); console.log('[post.html] Found', followedPubkeys.length, 'followed pubkeys from cache'); followedPubkeys.forEach(pk => feedPubkeys.add(pk)); // Query cache for posts from followed users console.log('[post.html] Querying cache for posts from followed users...'); try { const cachedPosts = await queryCache( { kinds: [1], authors: Array.from(feedPubkeys), limit: 50 } ); if (cachedPosts && cachedPosts.length > 0) { console.log('[post.html] Found', cachedPosts.length, 'cached posts from followed users'); cachedPosts.forEach(evt => { if (evt.id && evt.pubkey && !posts.some(p => p.id === evt.id)) { posts.push(evt); } }); posts.sort((a, b) => b.created_at - a.created_at); await renderFeed(); } } catch (cacheErr) { console.warn('[post.html] Cache query for followed posts failed (non-fatal):', cacheErr.message); } // Subscribe to posts from all followed users if (feedPubkeys.size > 0) { subscribe( { kinds: [1], authors: Array.from(feedPubkeys), limit: 50 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' } ); } } else { console.log('[post.html] No contact list in cache, will populate from subscription...'); } } else { // Single user mode — render cached posts now that interactions is ready const cachedPosts = await cachePostsPromise; if (cachedPosts && cachedPosts.length > 0) { console.log('[post.html] Found', cachedPosts.length, 'cached posts'); cachedPosts.forEach(evt => { if (evt.id && evt.pubkey && !posts.some(p => p.id === evt.id)) { posts.push(evt); } }); posts.sort((a, b) => b.created_at - a.created_at); await renderFeed(); } } // Initialize comments toggle button const commentsToggleButton = document.getElementById('commentsToggleButton'); if (commentsToggleButton) { // Set initial state - dimmed when hidden, normal when visible const initialCommentsVisible = interactions.getCommentsVisible(); commentsToggleButton.classList.toggle('dimmed', !initialCommentsVisible); // Handle toggle click commentsToggleButton.addEventListener('click', () => { const newVisible = !interactions.getCommentsVisible(); interactions.setCommentsVisible(newVisible); commentsToggleButton.classList.toggle('dimmed', !newVisible); }); } // Initialize See More button (hidden in viewed mode) const btnSeeMore = document.getElementById('btnSeeMore'); if (btnSeeMore && !showViewed) { btnSeeMore.addEventListener('click', async () => { console.log('[post.html] See More clicked - current displayCount:', displayCount, 'posts.length:', posts.length); if (displayCount < posts.length) { // We have more posts in memory — just show them displayCount += 20; console.log('[post.html] Showing more posts from memory, new displayCount:', displayCount); await renderFeed(); } else if (!isFetchingOlder && posts.length >= 50) { // Need to fetch older posts from relays // Only fetch if we have 50 posts (indicating there might be more) const oldestPost = posts[posts.length - 1]; if (oldestPost && feedPubkeys.size > 0) { isFetchingOlder = true; console.log('[post.html] Fetching older posts before:', oldestPost.created_at); displayCount += 20; // Use appropriate author list for fetching older posts const authors = showFollows ? Array.from(feedPubkeys) : [targetPubkey]; subscribe( { kinds: [1], authors: authors, limit: 50, until: oldestPost.created_at - 1 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' } ); // New events will arrive via ndkEvent listener and trigger renderFeed // Re-enable button after a delay setTimeout(() => { isFetchingOlder = false; }, 5000); } } }); } const sidenavWasOpen = localStorage.getItem('sidenavWasOpen'); if (sidenavWasOpen === 'true') { localStorage.removeItem('sidenavWasOpen'); openNav(); } updateIntervalId = setInterval(UpdateFooter, 1000); document.getElementById('versionDisplay').textContent = VERSION; console.log('[post.html] Initialization complete'); } catch (error) { console.error('[post.html] Initialization failed:', error); divBody.innerHTML = `
❌ Error
${error.message}
`; } })();