Files
client/build/.people-module-check.mjs
2026-04-17 16:52:51 -04:00

654 lines
23 KiB
JavaScript

import {
initNDKPage,
getPubkey,
injectHeaderAvatar,
subscribe,
publishEvent,
disconnect,
getVersion,
updateVersionDisplay,
queryCache,
fetchEventsFromAllRelays,
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';
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,
fetchEventsFromAllRelays,
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 = '50vw';
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 });
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 byRelay = await fetchEventsFromAllRelays({ kinds: [0], authors: batch });
const newestByPubkey = new Map();
for (const events of Object.values(byRelay || {})) {
if (!Array.isArray(events)) continue;
for (const evt of 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
}
}
dlog('hydrateProfiles:relay-fallback:batch-done', {
batchIndex,
batchTotal,
requested: batch.length,
applied
});
await renderPeople();
await new Promise((resolve) => setTimeout(resolve, 120));
} 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 (pubkey === currentPubkey) throw new Error('Cannot follow yourself from this page');
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');
fetchEventsFromAllRelays({ kinds: [3], authors: [currentPubkey], limit: 1 })
.then((byRelay) => {
const relayCount = Object.keys(byRelay || {}).length;
const totalEvents = Object.values(byRelay || {}).reduce((sum, arr) => sum + (Array.isArray(arr) ? arr.length : 0), 0);
dlog('loadInitialContacts:fetch-kind3-all-relays:done', { relayCount, totalEvents });
})
.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');
localStorage.setItem('sidenavWasOpen', isNavOpen ? 'true' : 'false');
window.location.reload();
});
logoutButton?.addEventListener('click', async () => {
try { await Logout(); } catch (error) { console.error(error); }
});
await initNDKPage();
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
await UpdateFooter();
wireEvents();
await loadInitialContacts();
const sidenavWasOpen = localStorage.getItem('sidenavWasOpen');
if (sidenavWasOpen === 'true') {
localStorage.removeItem('sidenavWasOpen');
openNav();
}
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();