938 lines
30 KiB
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>
|