Files
client/www/feed.html
T
Laan Tungir c758333269 Implement feed outbox model pre-warming (Phases 1-3) and relays.html connection history toggle with performance fixes (Phase 4)
Phase 1 (ndk-worker.js): Add warmOutbox, setRelayEventLogging, and getDiscoveredRelays RPC handlers. Gate broadcastRelayEvent and logRelayEvent IndexedDB writes behind relayEventLoggingEnabled flag (default off). trackRelayRead/trackRelayWrite left ungated to preserve footer activity carrots.

Phase 2 (init-ndk.mjs): Add warmOutbox, setRelayEventLogging, and getDiscoveredRelays page API exports with proper request/response wiring and timeouts.

Phase 3 (feed.html): Pre-warm outbox tracker via warmOutbox() before bootstrapFeedPosts and fetchFeedWindow calls so NDK routes author-based subscriptions to follows' write relays before short-lived fetchEvents EOSE.

Phase 4 (relays.html): Add 'Show connection history' sidenav toggle (sidenavRowToggle pattern from feed.html), gate ndkRelayEvent listener and DB loading behind connectionHistoryEnabled flag, switch live event stream to incremental DOM appending, sync state on init/beforeunload.
2026-06-26 09:38:31 -04:00

742 lines
23 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`;
}
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);
// Fire-and-forget: NDK.subscribe already calls trackUsers internally,
// but doing it explicitly avoids relying on internal timing.
void warmOutbox(authors).catch(() => {});
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) {
try {
await warmOutbox(newAuthors);
} catch (e) {
console.warn('[feed.html] warmOutbox for new authors failed (non-blocking):', e?.message || e);
}
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;
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>