Files
client/www/people.html

938 lines
30 KiB
HTML

<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>PEOPLE</title>
<link rel="stylesheet" href="./css/client.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: stretch !important;
justify-content: flex-start !important;
align-content: flex-start !important;
gap: 10px !important;
overflow-y: auto !important;
}
#divPeopleControls {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: space-between;
width: 100%;
border: var(--border);
border-radius: var(--border-radius);
padding: 10px;
background: var(--secondary-color);
}
.peopleControlGroup {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.peopleInput {
border: var(--border);
border-radius: var(--border-radius);
padding: 8px 10px;
font-size: 90%;
min-width: 240px;
max-width: 360px;
background: var(--secondary-color);
color: var(--primary-color);
}
.peopleButton {
border: var(--button-border-width) solid var(--button-border-color);
border-radius: var(--button-border-radius);
background: var(--button-background-color);
color: var(--button-color);
padding: 8px 10px;
font-size: 85%;
cursor: pointer;
transition: 0.2s;
}
.peopleButton:hover {
color: var(--button-hover-color);
border-color: var(--button-hover-color);
}
#divPeopleStatus {
width: 100%;
font-size: 80%;
color: var(--muted-color);
border: var(--border);
border-radius: var(--border-radius);
padding: 8px 10px;
min-height: 34px;
display: flex;
align-items: center;
background: var(--secondary-color);
}
#divPeopleGrid {
width: 100%;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 10px;
}
.personCard {
border: var(--border);
border-radius: var(--border-radius);
background: var(--secondary-color);
padding: 10px;
display: flex;
gap: 10px;
align-items: center;
min-height: 76px;
}
.personAvatarWrap {
width: 50px;
height: 50px;
flex: 0 0 50px;
border-radius: var(--border-radius);
border: var(--border-width) solid var(--primary-color);
overflow: hidden;
background: transparent;
cursor: pointer;
}
.personAvatar {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
filter: grayscale(var(--image-grayscale));
transition: filter 0.2s;
}
.personAvatar:hover {
filter: grayscale(var(--image-grayscale-hover));
}
.personMain {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
cursor: pointer;
}
.personName {
font-size: 92%;
color: var(--primary-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: bold;
}
.personPubkey {
font-size: 70%;
color: var(--muted-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.personRemove {
align-self: flex-start;
border: var(--button-border-width) solid var(--button-border-color);
border-radius: var(--button-border-radius);
background: var(--button-background-color);
color: var(--button-color);
padding: 4px 8px;
font-size: 70%;
cursor: pointer;
}
.personRemove:hover {
color: var(--button-hover-color);
border-color: var(--button-hover-color);
}
</style>
</head>
<body>
<div id="divSvgHam" class="divHeaderButtons"></div>
<div id="divHeader">
<div id="divHeaderFlexLeft"></div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText">PEOPLE</div>
</div>
<div id="divHeaderFlexRight"></div>
</div>
<div id="divBody">
<div id="divPeopleControls">
<div class="peopleControlGroup">
<input id="inpSearchPeople" class="peopleInput" type="text" placeholder="Search followed people" />
</div>
<div class="peopleControlGroup">
<input id="inpAddPerson" class="peopleInput" type="text" placeholder="Add by npub1... or hex pubkey" />
<button id="btnAddPerson" class="peopleButton">Add</button>
</div>
</div>
<div id="divPeopleStatus">Initializing...</div>
<div id="divPeopleGrid"></div>
</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>
<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">v0.0.1</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,
publishEvent,
disconnect,
getVersion,
updateVersionDisplay,
queryCache,
ndkFetchEvents,
fetchCachedProfile,
storeProfile
} from './js/init-ndk.mjs';
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
import { createProfileCache } from './js/profile-cache.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';
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
console.log(`[people.html ${VERSION}] Loading...`);
let updateIntervalId = null;
let currentPubkey = null;
let isNavOpen = false;
let hamburgerInstance = null;
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
let followedPubkeys = [];
let latestContactContent = {};
const profileCacheApi = createProfileCache({
fetchCachedProfile,
ndkFetchEvents,
storeProfile,
queryCache
});
const profileRelayRequested = new Set();
const PROFILE_BATCH_SIZE = 25;
const DEBUG_PEOPLE = true;
function dlog(...args) {
if (!DEBUG_PEOPLE) return;
console.log('[people.html]', ...args);
}
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 inpSearchPeople = document.getElementById('inpSearchPeople');
const inpAddPerson = document.getElementById('inpAddPerson');
const btnAddPerson = document.getElementById('btnAddPerson');
const divPeopleStatus = document.getElementById('divPeopleStatus');
const divPeopleGrid = document.getElementById('divPeopleGrid');
function setStatus(msg) {
divPeopleStatus.textContent = msg;
}
function shortPubkey(pk = '') {
return `${pk.slice(0, 8)}${pk.slice(-4)}`;
}
function parseFollowedPubkeysFromKind3(evt) {
const tags = Array.isArray(evt?.tags) ? evt.tags : [];
return tags
.filter((t) => Array.isArray(t) && t[0] === 'p' && typeof t[1] === 'string' && t[1].length === 64)
.map((t) => t[1]);
}
function dedupePubkeys(arr) {
return [...new Set((arr || []).filter((v) => typeof v === 'string' && v.length === 64))];
}
function normalizePubkeyInput(raw) {
const value = String(raw || '').trim();
if (!value) throw new Error('Enter npub or hex pubkey');
if (/^[a-f0-9]{64}$/i.test(value)) return value.toLowerCase();
if (value.startsWith('npub1')) {
const decoded = window?.NostrTools?.nip19?.decode?.(value);
if (decoded?.type === 'npub' && typeof decoded.data === 'string' && decoded.data.length === 64) {
return decoded.data;
}
}
throw new Error('Invalid pubkey format');
}
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = 'clamp(400px, 50vw, 600px)';
isNavOpen = true;
if (hamburgerInstance) hamburgerInstance.animateTo('arrow_left');
divSideNavBody.innerHTML = 'Settings and options coming soon...';
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() {
if (isNavOpen) closeNav(); else openNav();
}
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
async function UpdateFooter() {
try {
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
divFooterCenter.innerHTML = '';
divFooterRight.innerHTML = '';
} catch (error) {
console.error('[people.html] Error updating footer:', error);
}
}
async function Logout() {
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
disconnect();
if (window.NOSTR_LOGIN_LITE?.logout) await window.NOSTR_LOGIN_LITE.logout();
localStorage.clear();
sessionStorage.clear();
if (window.indexedDB) {
const databases = await window.indexedDB.databases();
for (const db of databases) if (db.name) window.indexedDB.deleteDatabase(db.name);
}
location.reload(true);
}
function personMatchesQuery(pubkey, profile, query) {
const q = String(query || '').trim().toLowerCase();
if (!q) return true;
const name = String(profile?.display_name || profile?.name || '').toLowerCase();
return name.includes(q) || pubkey.toLowerCase().includes(q);
}
function createAvatarElement(pubkey, profile) {
const wrap = document.createElement('div');
wrap.className = 'personAvatarWrap';
const img = document.createElement('img');
img.className = 'personAvatar';
img.alt = '';
img.loading = 'lazy';
img.decoding = 'async';
img.referrerPolicy = 'no-referrer';
const picture = String(profile?.picture || '').trim();
if (picture) {
img.addEventListener('error', () => {
img.removeAttribute('src');
}, { once: true });
img.src = picture;
}
wrap.appendChild(img);
wrap.addEventListener('click', () => {
window.location.href = `./post.html?npub=${encodeURIComponent(pubkey)}&profile=true&post=false`;
});
return wrap;
}
function getCachedProfile(pubkey) {
return profileCacheApi.getCachedProfile(pubkey) || null;
}
function getDisplayNameForSort(pubkey) {
const profile = getCachedProfile(pubkey);
const name = String(profile?.display_name || profile?.name || '').trim();
return name;
}
function getOrderedPubkeys() {
const rows = followedPubkeys.map((pk) => {
const displayName = getDisplayNameForSort(pk);
return {
pubkey: pk,
hasDisplayName: Boolean(displayName),
key: (displayName || '').toLowerCase(),
fallback: pk.toLowerCase()
};
});
rows.sort((a, b) => {
if (a.hasDisplayName !== b.hasDisplayName) return a.hasDisplayName ? -1 : 1;
if (a.key !== b.key) return a.key.localeCompare(b.key);
return a.fallback.localeCompare(b.fallback);
});
return rows.map((r) => r.pubkey);
}
async function renderPeople() {
const query = inpSearchPeople.value;
const ordered = getOrderedPubkeys();
const visible = ordered.filter((pk) => personMatchesQuery(pk, getCachedProfile(pk), query));
divPeopleGrid.innerHTML = '';
if (visible.length === 0) {
const empty = document.createElement('div');
empty.className = 'personCard';
empty.textContent = followedPubkeys.length === 0 ? 'No followed people yet.' : 'No people match your search.';
divPeopleGrid.appendChild(empty);
return;
}
for (const pubkey of visible) {
const profile = getCachedProfile(pubkey);
const card = document.createElement('div');
card.className = 'personCard';
const avatar = createAvatarElement(pubkey, profile);
const main = document.createElement('div');
main.className = 'personMain';
main.addEventListener('click', () => {
window.location.href = `./post.html?npub=${encodeURIComponent(pubkey)}&profile=true&post=false`;
});
const name = document.createElement('div');
name.className = 'personName';
name.textContent = String(profile?.display_name || profile?.name || shortPubkey(pubkey));
const pk = document.createElement('div');
pk.className = 'personPubkey';
pk.textContent = pubkey;
main.appendChild(name);
main.appendChild(pk);
const btnRemove = document.createElement('button');
btnRemove.className = 'personRemove';
btnRemove.textContent = 'Remove';
btnRemove.addEventListener('click', async (ev) => {
ev.stopPropagation();
await removePerson(pubkey);
});
card.appendChild(avatar);
card.appendChild(main);
card.appendChild(btnRemove);
divPeopleGrid.appendChild(card);
}
}
let hydrateInFlight = null;
const hydratePendingPubkeys = new Set();
async function runHydrateProfiles(pubkeys) {
const ordered = getOrderedPubkeys().filter((pk) => pubkeys.includes(pk));
dlog('hydrateProfiles:start', {
requested: pubkeys.length,
ordered: ordered.length,
cachedAlready: ordered.filter((pk) => Boolean(getCachedProfile(pk))).length,
relayRequestedAlready: ordered.filter((pk) => profileRelayRequested.has(pk)).length
});
let cacheHitCount = 0;
let cacheMissCount = 0;
const cacheTasks = ordered.map(async (pk) => {
if (getCachedProfile(pk)) {
cacheHitCount++;
return;
}
try {
const profile = await profileCacheApi.fetchProfile(pk, { allowNetwork: false, backgroundRefresh: true, logPrefix: '[people.html]' });
if (profile) {
cacheHitCount++;
} else {
cacheMissCount++;
}
} catch (_e) {
cacheMissCount++;
// non-fatal
}
});
await Promise.all(cacheTasks);
dlog('hydrateProfiles:cache-phase-complete', { cacheHitCount, cacheMissCount, totalCachedNow: ordered.filter((pk) => Boolean(getCachedProfile(pk))).length });
await renderPeople();
const missing = ordered.filter((pk) => !getCachedProfile(pk) && !profileRelayRequested.has(pk));
dlog('hydrateProfiles:missing-after-cache', { missing: missing.length });
if (missing.length === 0) return;
setStatus(`Fetching ${missing.length} missing profiles from relays in batches...`);
for (let i = 0; i < missing.length; i += PROFILE_BATCH_SIZE) {
const batch = missing.slice(i, i + PROFILE_BATCH_SIZE);
batch.forEach((pk) => profileRelayRequested.add(pk));
const batchIndex = Math.floor(i / PROFILE_BATCH_SIZE) + 1;
const batchTotal = Math.ceil(missing.length / PROFILE_BATCH_SIZE);
dlog('hydrateProfiles:relay-batch', { batchIndex, batchTotal, size: batch.length, first: batch[0], last: batch[batch.length - 1] });
setStatus(`Fetching profile batch ${batchIndex}/${batchTotal}...`);
subscribe(
{ kinds: [0], authors: batch },
{ closeOnEose: true, cacheUsage: 'ONLY_RELAY' }
);
await new Promise((resolve) => setTimeout(resolve, 150));
}
setStatus(`Loaded ${followedPubkeys.length} followed people`);
dlog('hydrateProfiles:relay-phase-queued', { queued: missing.length });
const unresolved = ordered.filter((pk) => !getCachedProfile(pk));
if (unresolved.length > 0) {
dlog('hydrateProfiles:relay-fallback:needed', { unresolved: unresolved.length });
await fetchMissingProfilesFromAllRelays(unresolved);
}
}
async function hydrateProfiles(pubkeys) {
const requested = dedupePubkeys(pubkeys);
requested.forEach((pk) => hydratePendingPubkeys.add(pk));
if (hydrateInFlight) {
dlog('hydrateProfiles:guard-queued', {
requested: requested.length,
pending: hydratePendingPubkeys.size
});
return hydrateInFlight;
}
hydrateInFlight = (async () => {
try {
while (hydratePendingPubkeys.size > 0) {
const cyclePubkeys = Array.from(hydratePendingPubkeys);
hydratePendingPubkeys.clear();
dlog('hydrateProfiles:guard-cycle-start', { cycleCount: cyclePubkeys.length });
await runHydrateProfiles(cyclePubkeys);
}
} finally {
hydrateInFlight = null;
dlog('hydrateProfiles:guard-idle');
}
})();
return hydrateInFlight;
}
async function fetchMissingProfilesFromAllRelays(missingPubkeys) {
const rows = dedupePubkeys(missingPubkeys);
if (!rows.length) return;
dlog('hydrateProfiles:relay-fallback:start', { count: rows.length });
const applyProfileEvents = (events = [], source = 'unknown') => {
const newestByPubkey = new Map();
for (const evt of (Array.isArray(events) ? events : [])) {
if (!evt || evt.kind !== 0 || typeof evt.pubkey !== 'string') continue;
const prev = newestByPubkey.get(evt.pubkey);
if (!prev || Number(evt.created_at || 0) > Number(prev.created_at || 0)) {
newestByPubkey.set(evt.pubkey, evt);
}
}
let applied = 0;
for (const evt of newestByPubkey.values()) {
try {
const parsed = JSON.parse(evt.content || '{}');
profileCacheApi.seedProfileCache(evt.pubkey, parsed);
applied++;
} catch (_e) {
// non-fatal
}
}
return { applied, uniqueProfiles: newestByPubkey.size, source };
};
for (let i = 0; i < rows.length; i += PROFILE_BATCH_SIZE) {
const batch = rows.slice(i, i + PROFILE_BATCH_SIZE);
const batchIndex = Math.floor(i / PROFILE_BATCH_SIZE) + 1;
const batchTotal = Math.ceil(rows.length / PROFILE_BATCH_SIZE);
try {
const cached = await queryCache({ kinds: [0], authors: batch, limit: batch.length * 2 }).catch(() => []);
const cacheResult = applyProfileEvents(cached, 'cache');
dlog('hydrateProfiles:relay-fallback:cache-batch-done', {
batchIndex,
batchTotal,
requested: batch.length,
applied: cacheResult.applied,
uniqueProfiles: cacheResult.uniqueProfiles
});
if (cacheResult.applied > 0) {
await renderPeople();
}
void ndkFetchEvents({ kinds: [0], authors: batch }).then(async (allEvents) => {
const relayResult = applyProfileEvents(allEvents, 'relays');
dlog('hydrateProfiles:relay-fallback:relay-batch-done', {
batchIndex,
batchTotal,
requested: batch.length,
applied: relayResult.applied,
uniqueProfiles: relayResult.uniqueProfiles
});
if (relayResult.applied > 0) {
await renderPeople();
}
}).catch((error) => {
dlog('hydrateProfiles:relay-fallback:batch-error', {
batchIndex,
batchTotal,
message: error?.message || String(error)
});
});
} catch (error) {
dlog('hydrateProfiles:relay-fallback:batch-error', {
batchIndex,
batchTotal,
message: error?.message || String(error)
});
}
}
}
function followListsEqual(a, b) {
if (a.length !== b.length) return false;
const aSet = new Set(a);
for (const pk of b) {
if (!aSet.has(pk)) return false;
}
return true;
}
function applyKind3Event(evt, source = 'unknown') {
const next = dedupePubkeys(parseFollowedPubkeysFromKind3(evt));
const changed = !followListsEqual(followedPubkeys, next);
followedPubkeys = next;
for (const pk of [...profileRelayRequested]) {
if (!followedPubkeys.includes(pk)) profileRelayRequested.delete(pk);
}
dlog('applyKind3Event', {
source,
followCount: followedPubkeys.length,
changed,
preview: followedPubkeys.slice(0, 5)
});
if (typeof evt?.content === 'string' && evt.content.trim()) {
try {
latestContactContent = JSON.parse(evt.content);
} catch (_err) {
latestContactContent = {};
}
}
setStatus(`Loaded ${followedPubkeys.length} followed people (${source})`);
renderPeople();
if (changed || source === 'cache') {
hydrateProfiles(followedPubkeys);
}
}
async function publishContacts() {
const tags = followedPubkeys.map((pk) => ['p', pk]);
const event = {
created_at: Math.floor(Date.now() / 1000),
kind: 3,
tags,
content: JSON.stringify(latestContactContent || {})
};
return await publishEvent(event);
}
async function addPersonFromInput() {
try {
const pubkey = normalizePubkeyInput(inpAddPerson.value);
if (followedPubkeys.includes(pubkey)) {
setStatus('Already followed');
return;
}
followedPubkeys = dedupePubkeys([...followedPubkeys, pubkey]);
await renderPeople();
setStatus(`Adding ${shortPubkey(pubkey)}...`);
const result = await publishContacts();
setStatus(`Added ${shortPubkey(pubkey)} | relays ok: ${result.relayResults.successful.length}`);
inpAddPerson.value = '';
hydrateProfiles([pubkey]);
} catch (error) {
setStatus(`Add failed: ${error.message}`);
}
}
async function removePerson(pubkey) {
const ok = window.confirm(`Remove ${shortPubkey(pubkey)} from follows?`);
if (!ok) return;
const next = followedPubkeys.filter((pk) => pk !== pubkey);
followedPubkeys = next;
await renderPeople();
setStatus(`Removing ${shortPubkey(pubkey)}...`);
try {
const result = await publishContacts();
setStatus(`Removed ${shortPubkey(pubkey)} | relays ok: ${result.relayResults.successful.length}`);
} catch (error) {
setStatus(`Remove publish failed: ${error.message}`);
}
}
function wireEvents() {
inpSearchPeople.addEventListener('input', () => renderPeople());
btnAddPerson.addEventListener('click', addPersonFromInput);
inpAddPerson.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') addPersonFromInput();
});
window.addEventListener('ndkEvent', (event) => {
const evt = event.detail;
if (!evt || !evt.kind) return;
if (evt.kind === 3 && evt.pubkey === currentPubkey) {
dlog('ndkEvent:kind3', { created_at: evt.created_at, tags: Array.isArray(evt.tags) ? evt.tags.length : 0 });
applyKind3Event(evt, 'subscription');
}
if (evt.kind === 0 && followedPubkeys.includes(evt.pubkey)) {
try {
const parsed = JSON.parse(evt.content || '{}');
profileCacheApi.seedProfileCache(evt.pubkey, parsed);
dlog('ndkEvent:kind0-profile', {
pubkey: evt.pubkey,
hasName: Boolean(parsed?.display_name || parsed?.name),
hasPicture: Boolean(parsed?.picture)
});
renderPeople();
} catch (_e) {
dlog('ndkEvent:kind0-profile-parse-failed', { pubkey: evt.pubkey });
// non-fatal
}
}
});
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity } = event.detail;
setRelayActivityState(relayUrl, activity);
});
}
async function loadInitialContacts() {
setStatus('Loading follows from cache...');
try {
const cached = await queryCache({ kinds: [3], authors: [currentPubkey], limit: 1 });
dlog('loadInitialContacts:cache-result', { count: cached?.length || 0 });
if (cached?.length) {
const latest = [...cached].sort((a, b) => Number(b.created_at || 0) - Number(a.created_at || 0))[0];
dlog('loadInitialContacts:using-cached-kind3', { created_at: latest?.created_at, tagCount: Array.isArray(latest?.tags) ? latest.tags.length : 0 });
applyKind3Event(latest, 'cache');
} else {
setStatus('No cached follows yet; waiting for relay data...');
}
} catch (error) {
console.warn('[people.html] cache load failed:', error.message);
}
dlog('loadInitialContacts:subscribe-kind3-cache-first');
subscribe({ kinds: [3], authors: [currentPubkey], limit: 1 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
dlog('loadInitialContacts:fetch-kind3-all-relays:start');
ndkFetchEvents({ kinds: [3], authors: [currentPubkey], limit: 1 })
.then((allEvents) => {
dlog('loadInitialContacts:fetch-kind3-all-relays:done', { totalEvents: allEvents?.length || 0 });
})
.catch((err) => {
dlog('loadInitialContacts:fetch-kind3-all-relays:error', { message: err?.message || String(err) });
});
}
async function main() {
try {
initHamburgerMenu();
document.getElementById('divSvgHam')?.addEventListener('click', toggleNav);
const themeToggleButton = document.getElementById('themeToggleButton');
const logoutButton = document.getElementById('logoutButton');
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');
}
});
logoutButton?.addEventListener('click', async () => {
try { await Logout(); } catch (error) { console.error(error); }
});
await initNDKPage();
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
await UpdateFooter();
wireEvents();
await loadInitialContacts();
updateIntervalId = setInterval(UpdateFooter, 1000);
await updateVersionDisplay();
setStatus(`Ready | ${followedPubkeys.length} followed`);
} catch (error) {
console.error('[people.html] Initialization failed:', error);
divBody.innerHTML = `<div style="text-align:center;padding:40px;color:var(--accent-color);">${error.message}</div>`;
}
}
main();
</script>
</body>
</html>