1611 lines
62 KiB
JavaScript
1611 lines
62 KiB
JavaScript
|
|
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 += `<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 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 = `<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>`;
|
|
}
|
|
})();
|
|
|