Files
client/build/.post-module-check-latest.mjs
2026-04-17 16:52:51 -04:00

1994 lines
76 KiB
JavaScript

import { initNDKPage, getPubkey, injectHeaderAvatar, 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';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { mountComposer } from './js/post-composer.mjs';
import { createProfileCache } from './js/profile-cache.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 += `<div class="profile-banner"><img src="${profile.banner}" alt="" onerror="this.style.display='none'"></div>`;
}
// Profile info section
html += `<div class="profile-info">`;
// Avatar and name row
html += `<div class="profile-header-row">`;
if (profile.picture) {
html += `<img class="profile-avatar" src="${profile.picture}" alt="" onerror="this.style.display='none'">`;
}
html += `<div class="profile-name-section">`;
html += `<div class="profile-display-name">${displayName}</div>`;
if (profile.name && profile.name !== displayName) {
html += `<div class="profile-name">@${profile.name}</div>`;
}
if (nip05) {
html += `<div class="profile-nip05">${nip05}</div>`;
}
html += `</div></div>`;
// About
if (about) {
html += `<div class="profile-about">${about}</div>`;
}
// Links
html += `<div class="profile-links">`;
if (website) {
html += `<a href="${website}" target="_blank" class="profile-link">${website}</a>`;
}
if (lud16) {
html += `<div class="profile-ln">⚡ ${lud16}</div>`;
}
html += `</div>`;
html += `</div>`;
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 name = pk.substring(0, 8) + '…';
try {
const profile = await profileCacheApi.fetchProfile(pk, { logPrefix: '[post.html]' });
if (profile) {
name = profile.display_name || profile.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;
prunePostState();
// 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();
await updateBlossomSection();
} 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 = 'btn viewed-sidenav-control';
btnMarkAllRead.style.width = '100%';
btnMarkAllRead.style.marginBottom = '10px';
btnMarkAllRead.textContent = 'Mark all as read';
btnMarkAllRead.addEventListener('click', markEverythingAsRead);
const btnUnmarkAll = document.createElement('button');
btnUnmarkAll.id = 'btnUnmarkAll';
btnUnmarkAll.className = 'btn viewed-sidenav-control';
btnUnmarkAll.style.width = '100%';
btnUnmarkAll.style.marginBottom = '10px';
btnUnmarkAll.textContent = 'Mark all as unread';
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));
prunePostState();
} 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);
}
}
}
prunePostState();
// 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
const MAX_INTERACTION_SUBSCRIPTIONS = 25;
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 });
// Bound retained interaction subscriptions to avoid long-session bloat.
if (activeSubscriptions.size > MAX_INTERACTION_SUBSCRIPTIONS) {
const staleCount = activeSubscriptions.size - MAX_INTERACTION_SUBSCRIPTIONS;
const staleKeys = Array.from(activeSubscriptions.keys()).slice(0, staleCount);
staleKeys.forEach((staleKey) => {
const staleSub = activeSubscriptions.get(staleKey);
if (staleSub?.unsubscribe) staleSub.unsubscribe();
activeSubscriptions.delete(staleKey);
});
}
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;
const MAX_POSTS = 300;
const MAX_UNREAD_POSTS = 300;
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
let composerInstance = null;
const inlineComposerInstances = new Map(); // postId -> { hostEl, instance, tags }
const composerFollowedPubkeys = new Set();
const composerFollowedProfiles = new Map();
const profileCacheApi = createProfileCache({
fetchCachedProfile,
fetchEventsFromAllRelays,
storeProfile,
queryCache
});
let lastPostAttemptCreatedAt = 0;
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
});
}
function prunePostState() {
posts.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
if (posts.length > MAX_POSTS) {
const removedPosts = posts.slice(MAX_POSTS);
const removedIds = new Set(removedPosts.map(p => p.id).filter(Boolean));
posts = posts.slice(0, MAX_POSTS);
removedIds.forEach((id) => {
renderedPostIds.delete(id);
pendingEvents.delete(id);
const postEl = divFeed?.querySelector(`.divPostItem[data-post-id="${id}"]`);
if (postEl) postEl.remove();
});
if (displayCount > posts.length) {
displayCount = posts.length;
}
unreadPosts = unreadPosts.filter((p) => !removedIds.has(p.id));
}
if (unreadPosts.length > MAX_UNREAD_POSTS) {
unreadPosts.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
unreadPosts = unreadPosts.slice(0, MAX_UNREAD_POSTS);
}
newestRenderedTimestamp = posts.length > 0 ? (posts[0].created_at || 0) : 0;
}
async function renderFeed() {
prunePostState();
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 (exclude quote-notes that mention the post)
if (event && event.kind === 1 && interactions.isCommentEvent?.(event, postId)) {
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-post-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 (not a quote-note), add it to the DOM
if (event && event.kind === 1 && interactions.isCommentEvent?.(event, postId)) {
interactions.addCommentToPost(postId, {
id: event.id,
pubkey: event.pubkey,
content: event.content,
created_at: event.created_at,
tags: event.tags
}, currentPubkey);
}
}
);
}
}
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 || !showPostBox) 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: getComposerFollowedProfiles(),
showUploadIcon: true,
showPreview: true,
autoHideOnSubmit: true,
onSubmit: async (content) => {
await handlePost({
content,
tags: entry.tags,
sourceEditorEl: entry.hostEl,
sourceComposerInstance: entry.instance
});
}
});
entry.instance = instance;
inlineComposerInstances.set(postId, entry);
return entry;
}
function handleComposerCommentIntent({ postId, postPubkey }) {
if (!showPostBox) return false;
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 }) {
if (!showPostBox) return false;
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 handlePost(options = {}) {
const sourceEditorEl = options.sourceEditorEl || divPost;
const sourceComposerInstance = options.sourceComposerInstance || composerInstance;
const content = String(options.content ?? sourceEditorEl?.innerText ?? '').trim();
if (!content) return;
console.log('[post.html][debug] handlePost start', {
contentLength: content.length,
showFollows,
showViewed,
targetPubkey,
currentPubkey,
feedPubkeysSize: feedPubkeys.size,
postsSizeBefore: posts.length,
renderedPostIdsSizeBefore: renderedPostIds.size
});
const now = Math.floor(Date.now() / 1000);
lastPostAttemptCreatedAt = now;
let tags = [];
if (Array.isArray(options.tags)) {
tags = options.tags;
} else {
try {
const parsed = JSON.parse(sourceEditorEl?.dataset?.intentTags || '[]');
tags = Array.isArray(parsed) ? parsed : [];
} catch (_) {
tags = [];
}
}
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);
console.log('[post.html][debug] pending post inserted into posts[]', {
pendingId: pendingPost.id,
postsSizeAfterPush: posts.length,
hasInRenderedBeforePrune: renderedPostIds.has(pendingPost.id)
});
prunePostState();
renderFeed();
console.log('[post.html] 🖥️ Pending post rendered immediately');
console.log('[post.html][debug] after immediate renderFeed()', {
pendingId: pendingPost.id,
hasInRenderedAfterRender: renderedPostIds.has(pendingPost.id),
divFeedChildCount: divFeed?.children?.length || 0
});
}
} else {
console.log('[post.html] ⚠️ No event ID in result:', result);
}
// Clear the input
console.log('[post.html] 🧹 Clearing post box');
if (sourceEditorEl) {
sourceEditorEl.innerHTML = '';
sourceEditorEl.innerText = '';
delete sourceEditorEl.dataset.intentTags;
}
if (sourceComposerInstance?.refresh) {
sourceComposerInstance.refresh({ followedProfiles: getComposerFollowedProfiles() });
}
if (sourceEditorEl === divPost) {
autoGrowPostBox();
}
} catch (error) {
console.error('[post.html] ❌ Publish failed:', error);
}
}
function autoGrowPostBox() {
if (!divPost || !showPostBox) return;
const minHeight = 100;
divPost.style.height = 'auto';
divPost.style.height = `${Math.max(divPost.scrollHeight, minHeight)}px`;
}
function handlePostInput() {
autoGrowPostBox();
}
function getComposerFollowedProfiles() {
return Array.from(composerFollowedProfiles.values());
}
async function upsertComposerProfile(pubkey) {
if (!pubkey) return;
const fallbackNpub = (() => {
try {
return window.NostrTools.nip19.npubEncode(pubkey);
} catch (_) {
return null;
}
})();
let profile = null;
try {
profile = await profileCacheApi.fetchProfile(pubkey, { logPrefix: '[post.html]' });
} catch (_) {}
const merged = {
pubkey,
npub: fallbackNpub,
name: profile?.name || '',
display_name: profile?.display_name || '',
nip05: profile?.nip05 || '',
picture: profile?.picture || ''
};
composerFollowedProfiles.set(pubkey, merged);
}
async function refreshComposerFollowedProfiles(pubkeys = []) {
if (!showPostBox) return;
const unique = Array.from(new Set(pubkeys.filter(Boolean)));
if (unique.length === 0) return;
await Promise.all(unique.map((pk) => upsertComposerProfile(pk)));
if (composerInstance) {
composerInstance.refresh({ followedProfiles: getComposerFollowedProfiles() });
}
inlineComposerInstances.forEach((entry) => {
if (entry?.instance?.refresh) {
entry.instance.refresh({ followedProfiles: getComposerFollowedProfiles() });
}
});
}
async function ingestComposerContactListEvent(evt) {
if (!showPostBox || !evt || evt.kind !== 3 || evt.pubkey !== currentPubkey) return;
const pTags = evt.tags?.filter((tag) => tag[0] === 'p' && tag[1]) || [];
const newlySeen = [];
pTags.forEach((tag) => {
const pk = tag[1];
if (!composerFollowedPubkeys.has(pk)) {
composerFollowedPubkeys.add(pk);
newlySeen.push(pk);
}
});
if (newlySeen.length > 0) {
await refreshComposerFollowedProfiles(newlySeen);
}
}
const UpdateFooter = async () => {
try {
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
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 listeners
if (showPostBox) {
divPost.addEventListener('input', handlePostInput);
window.addEventListener('resize', autoGrowPostBox);
requestAnimationFrame(() => {
autoGrowPostBox();
});
} 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();
await injectHeaderAvatar(currentPubkey);
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();
await initBlossomSection();
// 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();
}
})();
}
}
// Composer mentions use current user's follow list
if (evt.kind === 3 && evt.pubkey === currentPubkey && showPostBox) {
ingestComposerContactListEvent(evt);
}
// 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) {
const isFeedAuthor = feedPubkeys.has(evt.pubkey);
const isOwnPostForComposer = showPostBox && evt.pubkey === currentPubkey;
const alreadyInPosts = posts.some(p => p.id === evt.id);
console.log('[post.html][debug] kind1 event received', {
id: evt.id,
pubkey: evt.pubkey,
isFeedAuthor,
isOwnPostForComposer,
alreadyInPosts,
pending: pendingEvents.has(evt.id),
feedPubkeysSize: feedPubkeys.size,
showViewed,
showFollows,
showPostBox
});
if (isFeedAuthor || isOwnPostForComposer) {
// Check if already have this post
if (!alreadyInPosts) {
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);
prunePostState();
renderFollowsList();
renderViewedFeed();
}
} else if (pendingEvents.has(evt.id)) {
// Our own just-published post
pendingEvents.delete(evt.id);
if (isOriginalPost(evt)) {
posts.push(evt);
prunePostState();
debouncedRenderFeed();
}
} else {
// Historical or live post from relay/cache
if (isOriginalPost(evt)) {
posts.push(evt);
prunePostState();
// Ensure freshly received own posts appear immediately even when
// displayCount slicing would otherwise hide them this render pass.
if (
isOwnPostForComposer &&
!renderedPostIds.has(evt.id) &&
lastPostAttemptCreatedAt > 0 &&
(evt.created_at || 0) >= (lastPostAttemptCreatedAt - 10)
) {
const postEl = createPostElement(evt);
divFeed.prepend(postEl);
renderedPostIds.add(evt.id);
newestRenderedTimestamp = Math.max(newestRenderedTimestamp, evt.created_at || 0);
console.log('[post.html][debug] force-prepended own post to DOM', {
id: evt.id,
created_at: evt.created_at,
divFeedChildCount: divFeed?.children?.length || 0
});
}
debouncedRenderFeed();
}
}
}
} else {
console.log('[post.html][debug] kind1 ignored (author not in scope)', {
id: evt.id,
pubkey: evt.pubkey,
currentPubkey,
feedPubkeysSize: feedPubkeys.size
});
}
}
// 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.className = 'btn';
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
? profileCacheApi.fetchProfile(targetPubkey, { logPrefix: '[post.html]' }).catch(() => null)
: Promise.resolve(null);
// Initialize post interactions module — runs while cache queries are in-flight
interactions = initInteractions({
subscribe,
publishEvent,
getPubkey,
fetchEventsFromAllRelays,
fetchCachedProfile,
storeProfile,
onCommentIntent: handleComposerCommentIntent,
onQuoteIntent: handleComposerQuoteIntent
});
console.log('[post.html] Post interactions module initialized');
if (showPostBox && divPost) {
composerInstance = mountComposer(divPost, {
currentPubkey,
followedProfiles: getComposerFollowedProfiles(),
showUploadIcon: true,
showPreview: true,
onSubmit: async (content) => {
await handlePost({ content });
}
});
}
// Pre-warm current user's profile to avoid temporary "(unknown)" labels
// when newly posted self-comments render before async profile fetch completes.
try {
const myCachedProfile = await profileCacheApi.fetchProfile(currentPubkey, { logPrefix: '[post.html]' });
if (myCachedProfile && interactions?.seedProfileCache) {
interactions.seedProfileCache(currentPubkey, myCachedProfile);
}
} catch (_) {}
// 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(() => {});
}
if (showPostBox) {
subscribe(
{ kinds: [3], authors: [currentPubkey], limit: 1 },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
fetchEventsFromAllRelays({ kinds: [3], authors: [currentPubkey], limit: 1 }).catch(() => {});
queryCache({ kinds: [3], authors: [currentPubkey], limit: 1 })
.then((events) => {
if (events && events.length > 0) {
const latest = events.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0];
ingestComposerContactListEvent(latest);
}
})
.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 && isOriginalPost(evt) && !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 && isOriginalPost(evt) && !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 = `<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>`;
}
})();