Split renderFeed() into full rebuild + prependPostCard() on both feed.html and post.html. New live events now prepend a single card at the top of the feed without rebuilding existing cards, eliminating O(n) DOM rebuilds on every incoming event. DOM is trimmed to displayCount on overflow with renderedPostIds kept in sync. Full rebuild path preserved for See More, mute removal, bootstrap completion, and event mode. Also added implementation checklist to plans/long-running-memory-bloat-fix.md.
786 lines
25 KiB
HTML
786 lines
25 KiB
HTML
<!DOCTYPE html>
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<html lang="en" dir="ltr">
|
|
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>FEED</title>
|
|
|
|
<link rel="stylesheet" href="./css/client.css" />
|
|
<link rel="stylesheet" href="./css/post-composer.css" />
|
|
<link rel="stylesheet" href="./css/dot-menu.css" />
|
|
|
|
<script>
|
|
(function () {
|
|
const savedTheme = localStorage.getItem('theme');
|
|
if (savedTheme === 'dark') {
|
|
document.documentElement.classList.add('dark-mode');
|
|
if (document.body) document.body.classList.add('dark-mode');
|
|
}
|
|
})();
|
|
</script>
|
|
|
|
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
|
|
<script src="./js/vendor/svg.min.js"></script>
|
|
|
|
<style>
|
|
#divBody {
|
|
flex-direction: column !important;
|
|
flex-wrap: nowrap !important;
|
|
align-items: center !important;
|
|
justify-content: flex-start !important;
|
|
align-content: flex-start !important;
|
|
gap: 10px;
|
|
overflow-y: auto;
|
|
overflow-anchor: none;
|
|
}
|
|
|
|
#divHint,
|
|
#divFeed,
|
|
#btnSeeMore {
|
|
width: 80%;
|
|
min-width: 300px;
|
|
max-width: 700px;
|
|
}
|
|
|
|
#divHint {
|
|
text-align: center;
|
|
color: var(--muted-color);
|
|
font-size: 80%;
|
|
margin-top: 5px;
|
|
}
|
|
|
|
#divFeed {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
#btnSeeMore {
|
|
display: none;
|
|
margin-bottom: 20px;
|
|
}
|
|
.divPostItem .post-composer-wrapper {
|
|
width: 100%;
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<div id="divSvgHam" class="divHeaderButtons"></div>
|
|
|
|
<div id="divHeader">
|
|
<div id="divHeaderFlexLeft"></div>
|
|
<div id="divHeaderFlexCenter">
|
|
<div class="divHeaderText">FEED</div>
|
|
</div>
|
|
<div id="divHeaderFlexRight"></div>
|
|
</div>
|
|
|
|
<div id="divBody">
|
|
<div id="divHint">Loading follows…</div>
|
|
<div id="divFeed"></div>
|
|
<button id="btnSeeMore" class="btn">See More</button>
|
|
</div>
|
|
|
|
<div id="divFooter">
|
|
<div id="divFooterLeft" class="divFooterBox"></div>
|
|
<div id="divFooterCenter" class="divFooterBox"></div>
|
|
<div id="divFooterRight" class="divFooterBox"></div>
|
|
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
|
|
</div>
|
|
|
|
<div id="divSideNav">
|
|
<div id="divSideNavHeader"></div>
|
|
<div id="divSideNavBody">
|
|
<div id="divFiles"></div>
|
|
<div id="divFeedSettings" class="sidenavSection">
|
|
<div class="sidenavSectionTitle">Feed</div>
|
|
<label class="sidenavRowToggle" for="chkAutoplayVideos">
|
|
<span>Autoplay videos</span>
|
|
<input id="chkAutoplayVideos" type="checkbox" />
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="divAiSection" class="sidenavSection">
|
|
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
|
|
<div id="divAiList" class="sidenavSectionList">
|
|
<div id="divAiProvidersList">No saved providers yet.</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<div id="divRelaySection">
|
|
<div id="divRelaySectionTitle">リレー</div>
|
|
<div id="divRelayList">Loading relays...</div>
|
|
</div>
|
|
|
|
<div id="divBlossomSection">
|
|
<div id="divBlossomSectionTitle">ブロッサム</div>
|
|
<div id="divBlossomList">Loading blossom servers...</div>
|
|
</div>
|
|
|
|
<div id="divVersionBar">
|
|
<span id="versionDisplay">loading...</span>
|
|
<div id="divVersionBarButtons">
|
|
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
|
|
<div id="themeToggleHamburgerContainer"></div>
|
|
</button>
|
|
<button id="logoutButton" title="Logout">
|
|
<div id="logoutHamburgerContainer"></div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="./nostr.bundle.js"></script>
|
|
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
|
|
|
<script type="module">
|
|
import {
|
|
initNDKPage,
|
|
getPubkey,
|
|
injectHeaderAvatar,
|
|
subscribe,
|
|
disconnect,
|
|
getVersion,
|
|
queryCache,
|
|
ndkFetchEvents,
|
|
fetchCachedProfile,
|
|
storeProfile,
|
|
publishEvent,
|
|
walletPayInvoice,
|
|
walletSendNutzap,
|
|
walletFetchMintList,
|
|
walletGetBalance,
|
|
walletGetMints,
|
|
getRelayData,
|
|
getUserSettings,
|
|
patchUserSettings,
|
|
onUserSettings,
|
|
ensureMuteListLoaded,
|
|
isEventMuted,
|
|
addMute,
|
|
warmOutbox
|
|
} from './js/init-ndk.mjs';
|
|
import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs';
|
|
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
|
|
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
|
|
|
|
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
|
|
import { initPostCards } from './js/post-interactions2.mjs';
|
|
import { mountComposer } from './js/post-composer.mjs';
|
|
|
|
const INITIAL_POSTS_LOAD = 10;
|
|
const SEE_MORE_INCREMENT = 20;
|
|
const MIN_BOOTSTRAP_POSTS = 10;
|
|
const FEED_BOOTSTRAP_WINDOWS_SECONDS = [3600, 6 * 3600, 24 * 3600, 7 * 24 * 3600, 30 * 24 * 3600];
|
|
const FEED_BOOTSTRAP_WINDOW_LIMIT = 120;
|
|
|
|
let currentPubkey = null;
|
|
let updateIntervalId = null;
|
|
|
|
let unsubscribeUserSettings = null;
|
|
|
|
let hamburgerInstance = null;
|
|
let logoutHamburger = null;
|
|
let themeToggleHamburger = null;
|
|
let isDarkMode = false;
|
|
let isNavOpen = false;
|
|
|
|
const posts = [];
|
|
const postIds = new Set();
|
|
const renderedPostIds = new Set();
|
|
let displayCount = INITIAL_POSTS_LOAD;
|
|
|
|
let followedPubkeys = [];
|
|
const feedPubkeys = new Set();
|
|
|
|
const divBody = document.getElementById('divBody');
|
|
const divSideNav = document.getElementById('divSideNav');
|
|
const divFooterRight = document.getElementById('divFooterRight');
|
|
const divFeed = document.getElementById('divFeed');
|
|
const divHint = document.getElementById('divHint');
|
|
const btnSeeMore = document.getElementById('btnSeeMore');
|
|
const chkAutoplayVideos = document.getElementById('chkAutoplayVideos');
|
|
|
|
let autoplayVideosEnabled = false;
|
|
let liveFeedSubscriptionKey = '';
|
|
|
|
const inlineComposerInstances = new Map(); // postId -> { hostEl, instance, tags }
|
|
|
|
let isBootstrappingFeed = false;
|
|
let hasBootstrappedFeed = false;
|
|
const bufferedFeedEvents = [];
|
|
|
|
function toNostrNoteRef(postId) {
|
|
try {
|
|
return window?.NostrTools?.nip19?.noteEncode
|
|
? window.NostrTools.nip19.noteEncode(postId)
|
|
: postId;
|
|
} catch (_) {
|
|
return postId;
|
|
}
|
|
}
|
|
|
|
function focusInlineComposer(hostEl, content = '') {
|
|
if (!hostEl) return false;
|
|
hostEl.innerText = content || '';
|
|
hostEl.focus();
|
|
document.dispatchEvent(new Event('selectionchange'));
|
|
hostEl.dispatchEvent(new Event('input', { bubbles: true }));
|
|
window.requestAnimationFrame(() => {
|
|
hostEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
});
|
|
return true;
|
|
}
|
|
|
|
function getOrCreateInlineComposer(postId, postEl) {
|
|
if (!postId || !postEl) return null;
|
|
|
|
const existing = inlineComposerInstances.get(postId);
|
|
if (existing?.hostEl?.isConnected && existing?.instance) {
|
|
return existing;
|
|
}
|
|
|
|
const hostEl = document.createElement('div');
|
|
hostEl.className = 'inlinePostComposerInput';
|
|
hostEl.dataset.inlineComposerFor = postId;
|
|
postEl.appendChild(hostEl);
|
|
|
|
const entry = {
|
|
hostEl,
|
|
tags: [],
|
|
instance: null
|
|
};
|
|
|
|
const instance = mountComposer(hostEl, {
|
|
currentPubkey,
|
|
followedProfiles: [],
|
|
showUploadIcon: true,
|
|
showPreview: true,
|
|
autoHideOnSubmit: true,
|
|
hideOnEscape: true,
|
|
onSubmit: async (content) => {
|
|
const text = String(content || '').trim();
|
|
if (!text) return;
|
|
|
|
await publishEvent({
|
|
kind: 1,
|
|
content: text,
|
|
tags: entry.tags,
|
|
created_at: Math.floor(Date.now() / 1000)
|
|
});
|
|
}
|
|
});
|
|
|
|
entry.instance = instance;
|
|
inlineComposerInstances.set(postId, entry);
|
|
return entry;
|
|
}
|
|
|
|
function handleComposerCommentIntent({ postId }) {
|
|
if (!postId) return false;
|
|
window.open('./post-feed.html?event=' + encodeURIComponent(postId), '_blank');
|
|
return true;
|
|
}
|
|
|
|
function handleComposerQuoteIntent({ postId, postPubkey }) {
|
|
const postEl = document.querySelector(`.divPostItem[data-post-id="${postId}"]`);
|
|
if (!postEl) return false;
|
|
|
|
const noteRef = toNostrNoteRef(postId);
|
|
const entry = getOrCreateInlineComposer(postId, postEl);
|
|
if (!entry) return false;
|
|
entry.tags = [
|
|
['e', postId, '', 'mention'],
|
|
['p', postPubkey],
|
|
['q', postId]
|
|
];
|
|
if (entry.instance?.show) entry.instance.show();
|
|
return focusInlineComposer(entry.hostEl, `nostr:${noteRef}`);
|
|
}
|
|
|
|
async function handleMuteIntent({ eventData }) {
|
|
const targetPubkey = String(eventData?.pubkey || '').trim();
|
|
if (!targetPubkey || targetPubkey === currentPubkey) return;
|
|
|
|
const ok = window.confirm(`Mute ${targetPubkey.slice(0, 8)}…${targetPubkey.slice(-4)}?`);
|
|
if (!ok) return;
|
|
|
|
await addMute('p', targetPubkey, false);
|
|
|
|
for (let i = posts.length - 1; i >= 0; i -= 1) {
|
|
if (posts[i]?.pubkey === targetPubkey) {
|
|
postIds.delete(posts[i]?.id);
|
|
posts.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
renderFeed();
|
|
}
|
|
|
|
const cards = initPostCards({
|
|
subscribe,
|
|
publishEvent,
|
|
getPubkey,
|
|
ndkFetchEvents,
|
|
queryCache,
|
|
fetchCachedProfile,
|
|
storeProfile,
|
|
walletPayInvoice,
|
|
walletSendNutzap,
|
|
walletFetchMintList,
|
|
walletGetBalance,
|
|
walletGetMints,
|
|
getRelayData,
|
|
getUserSettings,
|
|
patchUserSettings,
|
|
onCommentIntent: handleComposerCommentIntent,
|
|
onQuoteIntent: handleComposerQuoteIntent,
|
|
onMuteIntent: handleMuteIntent
|
|
});
|
|
|
|
// Disable inline comment rendering — the new feed shows posts only.
|
|
// Comments are viewed on the post-feed.html thread page (new tab).
|
|
if (typeof cards.setCommentsVisible === 'function') {
|
|
cards.setCommentsVisible(false);
|
|
}
|
|
|
|
function initHamburgerMenu() {
|
|
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
|
|
foreground: 'var(--primary-color)',
|
|
background: 'var(--secondary-color)',
|
|
hover: 'var(--accent-color)'
|
|
});
|
|
hamburgerInstance.animateTo('burger');
|
|
}
|
|
|
|
function openNav() {
|
|
divSideNav.style.zIndex = 3;
|
|
divSideNav.style.width = 'clamp(400px, 50vw, 600px)';
|
|
isNavOpen = true;
|
|
if (hamburgerInstance) hamburgerInstance.animateTo('arrow_left');
|
|
|
|
if (!logoutHamburger) {
|
|
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
|
|
size: 24,
|
|
foreground: 'var(--primary-color)',
|
|
background: 'var(--secondary-color)',
|
|
hover: 'var(--accent-color)'
|
|
});
|
|
logoutHamburger.animateTo('x');
|
|
}
|
|
|
|
if (!themeToggleHamburger) {
|
|
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
|
|
size: 24,
|
|
foreground: 'var(--primary-color)',
|
|
background: 'var(--secondary-color)',
|
|
hover: 'var(--accent-color)'
|
|
});
|
|
const savedTheme = localStorage.getItem('theme');
|
|
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
|
|
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
|
|
}
|
|
}
|
|
|
|
function closeNav() {
|
|
divSideNav.style.width = '0vw';
|
|
divSideNav.style.zIndex = -1;
|
|
isNavOpen = false;
|
|
if (hamburgerInstance) hamburgerInstance.animateTo('burger');
|
|
}
|
|
|
|
function toggleNav() {
|
|
isNavOpen ? closeNav() : openNav();
|
|
}
|
|
|
|
function isOriginalPost(event) {
|
|
const eTags = event.tags?.filter(t => t[0] === 'e') || [];
|
|
if (eTags.length === 0) return true;
|
|
return eTags.every(t => t[3] === 'mention');
|
|
}
|
|
|
|
function applyVideoAutoplaySetting(enabled) {
|
|
autoplayVideosEnabled = !!enabled;
|
|
if (chkAutoplayVideos) chkAutoplayVideos.checked = autoplayVideosEnabled;
|
|
|
|
const videoEls = Array.from(divFeed.querySelectorAll('video'));
|
|
videoEls.forEach((videoEl) => {
|
|
videoEl.controls = true;
|
|
videoEl.playsInline = true;
|
|
videoEl.setAttribute('playsinline', '');
|
|
videoEl.preload = 'metadata';
|
|
|
|
if (autoplayVideosEnabled) {
|
|
videoEl.autoplay = true;
|
|
videoEl.muted = true;
|
|
videoEl.setAttribute('autoplay', '');
|
|
videoEl.setAttribute('muted', '');
|
|
const playPromise = videoEl.play?.();
|
|
if (playPromise?.catch) playPromise.catch(() => {});
|
|
} else {
|
|
videoEl.autoplay = false;
|
|
videoEl.muted = false;
|
|
videoEl.removeAttribute('autoplay');
|
|
videoEl.removeAttribute('muted');
|
|
}
|
|
});
|
|
}
|
|
|
|
function applySettings(settings) {
|
|
const nextAutoplayEnabled = !!settings?.feed?.videoAutoplay;
|
|
applyVideoAutoplaySetting(nextAutoplayEnabled);
|
|
}
|
|
|
|
async function persistAutoplaySetting(enabled) {
|
|
try {
|
|
await patchUserSettings({
|
|
feed: {
|
|
videoAutoplay: !!enabled
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.warn('[feed.html] Failed to persist feed autoplay setting:', error?.message || error);
|
|
}
|
|
}
|
|
|
|
function upsertFeedPost(evt) {
|
|
if (!evt?.id || postIds.has(evt.id) || !isOriginalPost(evt)) return false;
|
|
if (isEventMuted(evt)) return false;
|
|
postIds.add(evt.id);
|
|
posts.push(evt);
|
|
return true;
|
|
}
|
|
|
|
function renderFeed() {
|
|
posts.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
|
|
const toShow = posts.slice(0, displayCount);
|
|
|
|
divFeed.innerHTML = '';
|
|
renderedPostIds.clear();
|
|
|
|
toShow.forEach((post) => {
|
|
const postEl = cards.createCard(post, { currentPubkey, autoWire: false, autoplayVideo: autoplayVideosEnabled });
|
|
divFeed.appendChild(postEl);
|
|
renderedPostIds.add(post.id);
|
|
});
|
|
|
|
cards.wireInteractions(toShow.map(p => p.id), { currentPubkey });
|
|
|
|
btnSeeMore.style.display = posts.length > displayCount ? 'block' : 'none';
|
|
divHint.textContent = followedPubkeys.length > 0
|
|
? `${posts.length} posts from ${followedPubkeys.length} follows`
|
|
: 'Follow users to populate your feed.';
|
|
divFooterRight.textContent = `${posts.length} posts`;
|
|
}
|
|
|
|
function prependPostCard(post) {
|
|
if (!post?.id || renderedPostIds.has(post.id)) return false;
|
|
|
|
// If feed is empty or not yet rendered, fall back to full rebuild.
|
|
if (divFeed.children.length === 0 || renderedPostIds.size === 0) {
|
|
renderFeed();
|
|
return false;
|
|
}
|
|
|
|
// Only prepend if the post is newer than the first currently visible post.
|
|
const firstChild = divFeed.firstElementChild;
|
|
const firstPostId = firstChild ? firstChild.getAttribute('data-post-id') : null;
|
|
const firstPost = firstPostId ? posts.find((p) => p.id === firstPostId) : null;
|
|
|
|
if (!firstPost || (post.created_at || 0) < (firstPost.created_at || 0)) {
|
|
// Post is older than the first visible post (or unknown) — full rebuild for safety.
|
|
renderFeed();
|
|
return false;
|
|
}
|
|
|
|
const postEl = cards.createCard(post, { currentPubkey, autoWire: false, autoplayVideo: autoplayVideosEnabled });
|
|
divFeed.insertBefore(postEl, divFeed.firstChild);
|
|
renderedPostIds.add(post.id);
|
|
|
|
cards.wireInteractions([post.id], { currentPubkey });
|
|
|
|
// Trim DOM to displayCount.
|
|
while (divFeed.children.length > displayCount) {
|
|
const lastChild = divFeed.lastElementChild;
|
|
if (!lastChild) break;
|
|
const removedId = lastChild.getAttribute('data-post-id');
|
|
if (removedId) renderedPostIds.delete(removedId);
|
|
divFeed.removeChild(lastChild);
|
|
}
|
|
|
|
btnSeeMore.style.display = posts.length > displayCount ? 'block' : 'none';
|
|
divHint.textContent = followedPubkeys.length > 0
|
|
? `${posts.length} posts from ${followedPubkeys.length} follows`
|
|
: 'Follow users to populate your feed.';
|
|
divFooterRight.textContent = `${posts.length} posts`;
|
|
|
|
return true;
|
|
}
|
|
|
|
async function fetchFeedWindow(authors, { since, limit = FEED_BOOTSTRAP_WINDOW_LIMIT, renderAfterCache = false } = {}) {
|
|
if (!Array.isArray(authors) || authors.length === 0) return;
|
|
const filters = { kinds: [1], authors, limit };
|
|
if (typeof since === 'number' && Number.isFinite(since) && since > 0) {
|
|
filters.since = since;
|
|
}
|
|
|
|
const cached = await queryCache(filters).catch(() => []);
|
|
cached.forEach((evt1) => {
|
|
upsertFeedPost(evt1);
|
|
});
|
|
|
|
if (renderAfterCache && posts.length > 0) {
|
|
renderFeed();
|
|
}
|
|
|
|
void ndkFetchEvents(filters).then((fresh) => {
|
|
(Array.isArray(fresh) ? fresh : []).forEach((evt1) => {
|
|
upsertFeedPost(evt1);
|
|
});
|
|
if (posts.length > 0) {
|
|
renderFeed();
|
|
}
|
|
}).catch(() => {});
|
|
}
|
|
|
|
async function bootstrapFeedPosts(authors) {
|
|
if (!Array.isArray(authors) || authors.length === 0) {
|
|
hasBootstrappedFeed = true;
|
|
renderFeed();
|
|
return;
|
|
}
|
|
|
|
isBootstrappingFeed = true;
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
await Promise.allSettled(
|
|
FEED_BOOTSTRAP_WINDOWS_SECONDS.map((windowSeconds) =>
|
|
fetchFeedWindow(authors, {
|
|
since: now - windowSeconds,
|
|
limit: FEED_BOOTSTRAP_WINDOW_LIMIT,
|
|
renderAfterCache: true
|
|
})
|
|
)
|
|
);
|
|
|
|
if (posts.length < MIN_BOOTSTRAP_POSTS) {
|
|
await fetchFeedWindow(authors, {
|
|
since: 0,
|
|
limit: FEED_BOOTSTRAP_WINDOW_LIMIT * 2,
|
|
renderAfterCache: true
|
|
});
|
|
}
|
|
|
|
bufferedFeedEvents.forEach((evt1) => {
|
|
if (feedPubkeys.has(evt1.pubkey)) {
|
|
upsertFeedPost(evt1);
|
|
}
|
|
});
|
|
bufferedFeedEvents.length = 0;
|
|
|
|
isBootstrappingFeed = false;
|
|
hasBootstrappedFeed = true;
|
|
renderFeed();
|
|
}
|
|
|
|
function ensureLiveFeedSubscription() {
|
|
const authors = Array.from(feedPubkeys).filter(Boolean);
|
|
if (authors.length === 0) return;
|
|
|
|
const key = authors.slice().sort().join(',');
|
|
if (key === liveFeedSubscriptionKey) return;
|
|
|
|
const newestKnown = posts.reduce((max, p) => Math.max(max, p?.created_at || 0), 0);
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const since = newestKnown > 0 ? Math.max(0, newestKnown - 30) : (now - 3600);
|
|
|
|
// NDK.subscribe already calls outboxTracker.trackUsers internally,
|
|
// so no explicit warmOutbox call is needed here.
|
|
|
|
subscribe(
|
|
{ kinds: [1], authors, since },
|
|
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
|
|
);
|
|
|
|
liveFeedSubscriptionKey = key;
|
|
console.log('[feed.html] Live feed subscription active', { authors: authors.length, since });
|
|
}
|
|
|
|
async function ingestContactList(evt) {
|
|
if (!evt || evt.kind !== 3 || evt.pubkey !== currentPubkey) return;
|
|
const pTags = evt.tags?.filter(tag => tag[0] === 'p' && tag[1]) || [];
|
|
followedPubkeys = pTags.map(tag => tag[1]);
|
|
|
|
const newAuthors = followedPubkeys.filter((pk) => !feedPubkeys.has(pk));
|
|
newAuthors.forEach((pk) => feedPubkeys.add(pk));
|
|
|
|
ensureLiveFeedSubscription();
|
|
|
|
const allAuthors = Array.from(feedPubkeys);
|
|
|
|
if (!hasBootstrappedFeed) {
|
|
// Pre-warm the outbox tracker so NDK knows each follow's write relays
|
|
// before the short-lived bootstrap fetchEvents calls fire.
|
|
try {
|
|
await warmOutbox(followedPubkeys);
|
|
} catch (e) {
|
|
console.warn('[feed.html] warmOutbox failed (non-blocking):', e?.message || e);
|
|
}
|
|
await bootstrapFeedPosts(allAuthors);
|
|
return;
|
|
}
|
|
|
|
if (newAuthors.length > 0) {
|
|
// NDK's fetchEvents/subscribe already calls outboxTracker.trackUsers
|
|
// internally for author-based filters, so no explicit warmOutbox needed.
|
|
try {
|
|
await fetchFeedWindow(newAuthors, { limit: INITIAL_POSTS_LOAD });
|
|
renderFeed();
|
|
} catch (_) {
|
|
renderFeed();
|
|
}
|
|
} else {
|
|
renderFeed();
|
|
}
|
|
}
|
|
|
|
async function logout() {
|
|
if (updateIntervalId) {
|
|
clearInterval(updateIntervalId);
|
|
updateIntervalId = null;
|
|
}
|
|
cards.destroy();
|
|
disconnect();
|
|
if (window.NOSTR_LOGIN_LITE?.logout) {
|
|
await window.NOSTR_LOGIN_LITE.logout();
|
|
}
|
|
if (unsubscribeUserSettings) {
|
|
unsubscribeUserSettings();
|
|
unsubscribeUserSettings = null;
|
|
}
|
|
localStorage.clear();
|
|
sessionStorage.clear();
|
|
location.reload(true);
|
|
}
|
|
|
|
async function updateFooter() {
|
|
try {
|
|
await updateFooterRelayStatus();
|
|
await updateSidenavRelaySection();
|
|
await updateBlossomSection();
|
|
} catch (_) {}
|
|
}
|
|
|
|
(async function main() {
|
|
try {
|
|
const versionInfo = await getVersion();
|
|
document.getElementById('versionDisplay').textContent = versionInfo.VERSION;
|
|
|
|
initHamburgerMenu();
|
|
document.getElementById('divSvgHam')?.addEventListener('click', toggleNav);
|
|
|
|
document.getElementById('themeToggleButton')?.addEventListener('click', () => {
|
|
isDarkMode = !isDarkMode;
|
|
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
|
|
document.documentElement.classList.toggle('dark-mode', isDarkMode);
|
|
document.body.classList.toggle('dark-mode', isDarkMode);
|
|
if (themeToggleHamburger) {
|
|
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
|
|
}
|
|
});
|
|
|
|
document.getElementById('logoutButton')?.addEventListener('click', logout);
|
|
|
|
btnSeeMore.addEventListener('click', () => {
|
|
displayCount += SEE_MORE_INCREMENT;
|
|
renderFeed();
|
|
});
|
|
|
|
chkAutoplayVideos?.addEventListener('change', async () => {
|
|
const nextEnabled = !!chkAutoplayVideos.checked;
|
|
applyVideoAutoplaySetting(nextEnabled);
|
|
await persistAutoplaySetting(nextEnabled);
|
|
});
|
|
|
|
await initNDKPage();
|
|
currentPubkey = await getPubkey();
|
|
await injectHeaderAvatar(currentPubkey);
|
|
|
|
await ensureMuteListLoaded();
|
|
|
|
try {
|
|
const settings = await getUserSettings();
|
|
applySettings(settings || {});
|
|
} catch (error) {
|
|
console.warn('[feed.html] Failed to load user settings:', error?.message || error);
|
|
applySettings({});
|
|
}
|
|
|
|
if (!unsubscribeUserSettings) {
|
|
unsubscribeUserSettings = onUserSettings((settings) => {
|
|
applySettings(settings || {});
|
|
});
|
|
}
|
|
|
|
initFooterRelayStatus();
|
|
initSidenavRelaySection();
|
|
await initBlossomSection();
|
|
initAiSectionWithLocalConfig();
|
|
await updateFooter();
|
|
updateIntervalId = setInterval(updateFooter, 1000);
|
|
|
|
window.addEventListener('ndkRelayActivity', (e) => {
|
|
setRelayActivityState(e.detail.relayUrl, e.detail.activity);
|
|
});
|
|
|
|
window.addEventListener('ndkEvent', async (e) => {
|
|
const evt = e.detail;
|
|
if (!evt) return;
|
|
|
|
if (evt.kind === 3 && evt.pubkey === currentPubkey) {
|
|
await ingestContactList(evt);
|
|
return;
|
|
}
|
|
|
|
if (evt.kind === 1 && feedPubkeys.has(evt.pubkey) && isOriginalPost(evt)) {
|
|
if (isBootstrappingFeed) {
|
|
bufferedFeedEvents.push(evt);
|
|
return;
|
|
}
|
|
if (!upsertFeedPost(evt)) return;
|
|
if (renderedPostIds.size > 0 && divFeed.children.length > 0) {
|
|
prependPostCard(evt);
|
|
} else {
|
|
renderFeed();
|
|
}
|
|
}
|
|
});
|
|
|
|
subscribe({ kinds: [3], authors: [currentPubkey], limit: 1 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
|
|
|
|
const cachedContactEvents = await queryCache({ kinds: [3], authors: [currentPubkey], limit: 1 }).catch(() => []);
|
|
if (cachedContactEvents.length > 0) {
|
|
const latest = cachedContactEvents.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0];
|
|
await ingestContactList(latest);
|
|
}
|
|
|
|
if (feedPubkeys.size === 0) {
|
|
divHint.textContent = 'Follow users to populate your feed.';
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('[feed.html] Initialization failed:', error);
|
|
divBody.innerHTML = `<div style="text-align:center;padding:50px;"><div style="font-size:24px;margin-bottom:20px;color:var(--primary-color);">❌ Error</div><div style="font-size:16px;color:var(--muted-color);">${error.message}</div></div>`;
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|