Files
client/www/post.html

968 lines
29 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>POST</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;
}
#divComposer,
#divProfileCard,
#divHint,
#divFeed,
#btnSeeMore {
width: 80%;
min-width: 300px;
max-width: 700px;
}
#divProfileCard {
display: none;
background: var(--card-background);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 12px;
box-sizing: border-box;
}
.profileCardHead {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.profileCardAvatar {
width: 52px;
height: 52px;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--border-color);
background: var(--secondary-color);
}
.profileCardName {
font-weight: 700;
line-height: 1.2;
overflow-wrap: anywhere;
}
.profileCardPubkey {
color: var(--muted-color);
font-size: 80%;
overflow-wrap: anywhere;
margin-top: 2px;
}
.profileCardAbout {
color: var(--text-color);
white-space: pre-wrap;
overflow-wrap: anywhere;
font-size: 95%;
}
.profileCardLoading {
color: var(--muted-color);
font-size: 90%;
}
#divHint {
text-align: center;
color: var(--muted-color);
font-size: 80%;
margin-top: 5px;
}
#divComposer {
margin-top: 5px;
}
#divComposer .post-composer-wrapper {
width: 100%;
}
.topPostComposerInput {
min-height: 6em;
padding: 8px;
line-height: 1.4;
border: 2px solid var(--primary-color);
border-radius: 10px;
}
#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">POST</div>
</div>
<div id="divHeaderFlexRight"></div>
</div>
<div id="divBody">
<div id="divComposer"></div>
<div id="divProfileCard"></div>
<div id="divHint">Loading your posts…</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">
<span id="spanBroadcastStatus"></span>
</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>
<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,
getRelayData,
getUserSettings,
patchUserSettings,
ensureMuteListLoaded,
isEventMuted,
addMute
} 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 = 20;
const SEE_MORE_INCREMENT = 20;
const MIN_BOOTSTRAP_POSTS = 10;
const BOOTSTRAP_WINDOWS_SECONDS = [24 * 3600, 7 * 24 * 3600, 30 * 24 * 3600, 90 * 24 * 3600];
const BOOTSTRAP_PER_WINDOW_LIMIT = 120;
const urlParams = new URLSearchParams(window.location.search);
const profileParamRaw = (urlParams.get('profile') || '').trim();
const npubParamRaw = (urlParams.get('npub') || '').trim();
const pubkeyParamRaw = (urlParams.get('pubkey') || '').trim();
const eventParamRaw = (urlParams.get('event') || '').trim();
const isProfileMode = profileParamRaw.length > 0;
const isEventMode = eventParamRaw.length > 0;
let currentPubkey = null;
let targetProfilePubkey = null;
let targetEventId = null;
let updateIntervalId = 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;
const divBody = document.getElementById('divBody');
const divComposer = document.getElementById('divComposer');
const divSideNav = document.getElementById('divSideNav');
const divFooterRight = document.getElementById('divFooterRight');
const divFeed = document.getElementById('divFeed');
const divHint = document.getElementById('divHint');
const divProfileCard = document.getElementById('divProfileCard');
const btnSeeMore = document.getElementById('btnSeeMore');
const inlineComposerInstances = new Map(); // postId -> { hostEl, instance, tags }
let isBootstrapping = true;
const bufferedLiveEvents = [];
function escapeHtml(value) {
const text = String(value || '');
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
function resolveProfileParamToPubkey(profileValue) {
const value = String(profileValue || '').trim();
if (!value) return null;
if (/^[0-9a-fA-F]{64}$/.test(value)) {
return value.toLowerCase();
}
try {
const decoded = window?.NostrTools?.nip19?.decode?.(value);
if (!decoded) return null;
if (decoded.type === 'npub' && typeof decoded.data === 'string' && /^[0-9a-fA-F]{64}$/.test(decoded.data)) {
return decoded.data.toLowerCase();
}
if (decoded.type === 'nprofile' && decoded.data?.pubkey && /^[0-9a-fA-F]{64}$/.test(decoded.data.pubkey)) {
return decoded.data.pubkey.toLowerCase();
}
} catch (_) {}
return null;
}
function resolveEventParamToId(eventValue) {
const raw = String(eventValue || '').trim();
if (!raw) return null;
const normalized = raw.toLowerCase().startsWith('nostr:') ? raw.slice(6) : raw;
if (/^[0-9a-fA-F]{64}$/.test(normalized)) {
return normalized.toLowerCase();
}
try {
const decoded = window?.NostrTools?.nip19?.decode?.(normalized);
if (!decoded) return null;
if (decoded.type === 'note' && typeof decoded.data === 'string' && /^[0-9a-fA-F]{64}$/.test(decoded.data)) {
return decoded.data.toLowerCase();
}
if (decoded.type === 'nevent' && decoded.data?.id && /^[0-9a-fA-F]{64}$/.test(decoded.data.id)) {
return decoded.data.id.toLowerCase();
}
} catch (_) {}
return null;
}
function encodeNpub(pubkey) {
try {
return window?.NostrTools?.nip19?.npubEncode
? window.NostrTools.nip19.npubEncode(pubkey)
: '';
} catch (_) {
return '';
}
}
async function fetchProfileMetadata(pubkey) {
const cached = await fetchCachedProfile(pubkey).catch(() => null);
if (cached) return cached;
const metaEvents = await ndkFetchEvents({
kinds: [0],
authors: [pubkey],
limit: 1
}).catch(() => []);
if (!Array.isArray(metaEvents) || metaEvents.length === 0) return null;
const latest = [...metaEvents].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0];
if (!latest?.content) return null;
try {
const parsed = JSON.parse(latest.content);
if (parsed && typeof parsed === 'object') {
await storeProfile(pubkey, parsed).catch(() => {});
return parsed;
}
} catch (_) {}
return null;
}
async function renderProfileCard() {
if (!divProfileCard) return;
if (!isProfileMode || !targetProfilePubkey) {
divProfileCard.style.display = 'none';
return;
}
divProfileCard.style.display = 'block';
divProfileCard.innerHTML = '<div class="profileCardLoading">Loading profile…</div>';
const profile = await fetchProfileMetadata(targetProfilePubkey);
const displayName = profile?.display_name || profile?.name || `${targetProfilePubkey.slice(0, 8)}${targetProfilePubkey.slice(-8)}`;
const about = profile?.about || '';
const picture = profile?.picture || '';
const npub = encodeNpub(targetProfilePubkey);
divProfileCard.innerHTML = `
<div class="profileCardHead">
<img class="profileCardAvatar" src="${escapeHtml(picture || './favicon/favicon-dots2.ico')}" alt="avatar" />
<div>
<div class="profileCardName">${escapeHtml(displayName)}</div>
<div class="profileCardPubkey">${escapeHtml(npub || targetProfilePubkey)}</div>
</div>
</div>
<div class="profileCardAbout">${escapeHtml(about || 'No profile bio.')}</div>
`;
}
function applyPageModeUI() {
const headerLabel = document.querySelector('#divHeader .divHeaderText');
if (isEventMode) {
if (headerLabel) headerLabel.textContent = 'EVENT';
if (divComposer) divComposer.style.display = 'none';
if (divHint) divHint.textContent = 'Loading event…';
return;
}
if (isProfileMode) {
if (headerLabel) headerLabel.textContent = 'PROFILE';
if (divComposer) divComposer.style.display = 'none';
if (divHint) divHint.textContent = 'Loading profile posts…';
} else {
if (headerLabel) headerLabel.textContent = 'POST';
if (divComposer) divComposer.style.display = '';
if (divHint) divHint.textContent = 'Loading your posts…';
}
}
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, postPubkey }) {
const postEl = document.querySelector(`.divPostItem[data-post-id="${postId}"]`);
if (!postEl) return false;
const entry = getOrCreateInlineComposer(postId, postEl);
if (!entry) return false;
entry.tags = [
['e', postId, '', 'reply'],
['p', postPubkey]
];
if (entry.instance?.show) entry.instance.show();
return focusInlineComposer(entry.hostEl, '');
}
function handleComposerQuoteIntent({ postId, postPubkey }) {
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,
getRelayData,
getUserSettings,
patchUserSettings,
onCommentIntent: handleComposerCommentIntent,
onQuoteIntent: handleComposerQuoteIntent,
onMuteIntent: handleMuteIntent
});
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 upsertPost(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: false });
divFeed.appendChild(postEl);
renderedPostIds.add(post.id);
});
cards.wireInteractions(toShow.map(p => p.id), { currentPubkey });
if (isEventMode) {
btnSeeMore.style.display = 'none';
const shownId = toShow[0]?.id || targetEventId || '';
divHint.textContent = toShow.length > 0
? `Showing event ${shownId.slice(0, 8)}`
: 'Event not found yet.';
divFooterRight.textContent = `${toShow.length} event${toShow.length === 1 ? '' : 's'}`;
return;
}
btnSeeMore.style.display = posts.length > displayCount ? 'block' : 'none';
divHint.textContent = posts.length > 0
? `${posts.length} posts`
: (isProfileMode ? 'No posts yet.' : 'No posts yet — write one above.');
divFooterRight.textContent = `${posts.length} posts`;
}
function renderSingleEvent(evt) {
if (!evt?.id) return;
posts.length = 0;
postIds.clear();
renderedPostIds.clear();
displayCount = 1;
if (isEventMuted(evt)) {
divFeed.innerHTML = '';
divHint.textContent = 'This event is muted.';
divFooterRight.textContent = '0 events';
return;
}
postIds.add(evt.id);
posts.push(evt);
renderFeed();
}
async function fetchOwnPostsWindow({ since, limit = BOOTSTRAP_PER_WINDOW_LIMIT, renderAfterCache = false }) {
const filters = { kinds: [1], authors: [targetProfilePubkey], limit };
if (typeof since === 'number' && Number.isFinite(since) && since > 0) {
filters.since = since;
}
const cached = await queryCache(filters).catch(() => []);
cached.forEach((evt) => {
upsertPost(evt);
});
if (renderAfterCache && posts.length > 0) {
renderFeed();
}
const fresh = await ndkFetchEvents(filters).catch(() => []);
fresh.forEach((evt) => {
upsertPost(evt);
});
}
async function bootstrapInitialPosts() {
const now = Math.floor(Date.now() / 1000);
await Promise.allSettled(
BOOTSTRAP_WINDOWS_SECONDS.map((windowSeconds) =>
fetchOwnPostsWindow({
since: now - windowSeconds,
limit: BOOTSTRAP_PER_WINDOW_LIMIT,
renderAfterCache: true
})
)
);
if (posts.length < MIN_BOOTSTRAP_POSTS) {
await fetchOwnPostsWindow({
since: 0,
limit: BOOTSTRAP_PER_WINDOW_LIMIT * 2,
renderAfterCache: true
});
}
bufferedLiveEvents.forEach((evt) => {
upsertPost(evt);
});
bufferedLiveEvents.length = 0;
isBootstrapping = false;
renderFeed();
}
async function mountTopComposer() {
if (!divComposer) return;
const hostEl = document.createElement('div');
hostEl.className = 'topPostComposerInput';
divComposer.appendChild(hostEl);
const focusTopComposer = () => {
requestAnimationFrame(() => {
hostEl.focus();
const selection = window.getSelection?.();
if (!selection) return;
const range = document.createRange();
range.selectNodeContents(hostEl);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
});
};
let topComposer = null;
topComposer = mountComposer(hostEl, {
currentPubkey,
followedProfiles: [],
showUploadIcon: true,
showPreview: true,
autoHideOnSubmit: false,
onSubmit: async (content) => {
const text = String(content || '').trim();
if (!text) return;
await publishEvent({
kind: 1,
content: text,
tags: [],
created_at: Math.floor(Date.now() / 1000)
});
topComposer?.clear();
focusTopComposer();
}
});
focusTopComposer();
}
async function logout() {
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
cards.destroy();
disconnect();
if (window.NOSTR_LOGIN_LITE?.logout) {
await window.NOSTR_LOGIN_LITE.logout();
}
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();
});
await initNDKPage();
// Live broadcast progress display in the footer center section.
// The worker streams per-relay publish results via this window event
// (see plans/broadcast-relays-feature.md §8).
const spanBroadcastStatus = document.getElementById('spanBroadcastStatus');
window.addEventListener('ndkBroadcastProgress', (event) => {
const d = event.detail;
if (!d || !spanBroadcastStatus) return;
if (d.phase === 'start') {
spanBroadcastStatus.textContent = `Broadcasting to ${d.total} relays…`;
} else if (d.phase === 'progress') {
const shortRelay = d.latestRelay
? d.latestRelay.replace(/^wss?:\/\//, '').replace(/\/.*$/, '').slice(0, 30)
: '';
spanBroadcastStatus.textContent = `📡 ${d.successful}/${d.total} relays · ${shortRelay}`;
} else if (d.phase === 'done') {
// Keep the result displayed until the next publish overwrites it.
spanBroadcastStatus.textContent = `${d.successful}/${d.total} relays` + (d.failed > 0 ? ` (${d.failed} failed)` : '');
}
});
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);
await ensureMuteListLoaded();
if (isEventMode) {
targetEventId = resolveEventParamToId(eventParamRaw);
if (!targetEventId) {
throw new Error(`Invalid event parameter: ${eventParamRaw}`);
}
targetProfilePubkey = currentPubkey;
} else {
targetProfilePubkey = isProfileMode
? (
resolveProfileParamToPubkey(profileParamRaw)
|| resolveProfileParamToPubkey(npubParamRaw)
|| resolveProfileParamToPubkey(pubkeyParamRaw)
)
: currentPubkey;
if (isProfileMode && !targetProfilePubkey) {
throw new Error(`Invalid profile parameter: ${profileParamRaw}`);
}
}
applyPageModeUI();
await renderProfileCard();
if (!isProfileMode && !isEventMode) {
await mountTopComposer();
}
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
await Promise.all([
updateFooterRelayStatus(),
updateSidenavRelaySection()
]);
void updateBlossomSection().catch(() => {});
updateIntervalId = setInterval(updateFooter, 5000);
window.addEventListener('ndkRelayActivity', (e) => {
setRelayActivityState(e.detail.relayUrl, e.detail.activity);
});
window.addEventListener('ndkEvent', (e) => {
const evt = e.detail;
if (!evt) return;
if (evt.kind !== 1) return;
if (isEventMode) {
if (!targetEventId || evt.id !== targetEventId) return;
if (postIds.has(evt.id)) return;
renderSingleEvent(evt);
return;
}
if (evt.pubkey !== targetProfilePubkey) return;
if (isBootstrapping) {
bufferedLiveEvents.push(evt);
return;
}
if (!upsertPost(evt)) return;
renderFeed();
});
if (isEventMode) {
let matched = null;
try {
const cachedEvent = await queryCache({ ids: [targetEventId], limit: 1 });
matched = Array.isArray(cachedEvent)
? cachedEvent.find((evt) => evt?.id === targetEventId) || cachedEvent[0]
: null;
} catch (_) {}
if (matched) {
renderSingleEvent(matched);
} else {
divFeed.innerHTML = '';
divHint.textContent = 'Loading event…';
divFooterRight.textContent = '0 events';
}
void ndkFetchEvents({ ids: [targetEventId], limit: 1 }).then((relayEvents) => {
const relayMatch = Array.isArray(relayEvents)
? relayEvents.find((evt) => evt?.id === targetEventId) || relayEvents[0]
: null;
if (relayMatch) {
renderSingleEvent(relayMatch);
return;
}
if (!matched) {
divFeed.innerHTML = '';
divHint.textContent = 'Event not found yet.';
divFooterRight.textContent = '0 events';
}
}).catch((error) => {
console.warn('[post.html] Event relay hydration failed:', error?.message || error);
if (!matched) {
divHint.textContent = 'Event not found yet.';
}
});
const liveSince = Math.floor(Date.now() / 1000) - (30 * 24 * 3600);
subscribe({ kinds: [1], ids: [targetEventId], since: liveSince }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
} else {
const liveSince = Math.floor(Date.now() / 1000) - (24 * 3600);
subscribe({ kinds: [1], authors: [targetProfilePubkey], since: liveSince }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
await bootstrapInitialPosts();
}
} 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>`;
}
})();
</script>
</body>
</html>