1473 lines
54 KiB
JavaScript
1473 lines
54 KiB
JavaScript
|
|
import {
|
|
initNDKPage,
|
|
getPubkey,
|
|
injectHeaderAvatar,
|
|
subscribe,
|
|
fetchEvents,
|
|
ndkFetchEvents,
|
|
queryCache,
|
|
getRelayData,
|
|
publishEvent,
|
|
publishRawEventToRelay,
|
|
disconnect,
|
|
getVersion,
|
|
updateVersionDisplay
|
|
} 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 { configureMuteList, loadMuteList, addMute } from './js/mute-list.mjs';
|
|
|
|
const versionInfo = await getVersion();
|
|
const VERSION = versionInfo.VERSION;
|
|
console.log(`[event-management.html ${VERSION}] Loading...`);
|
|
|
|
const KIND_LABELS = {
|
|
0: 'Profile',
|
|
1: 'Note',
|
|
2: 'Recommend Relay',
|
|
3: 'Contacts',
|
|
4: 'Encrypted DM',
|
|
5: 'Deletion',
|
|
6: 'Repost',
|
|
7: 'Reaction',
|
|
8: 'Badge Award',
|
|
40: 'Channel Create',
|
|
41: 'Channel Metadata',
|
|
42: 'Channel Message',
|
|
9734: 'Zap Request',
|
|
9735: 'Zap Receipt',
|
|
10000: 'Mute List',
|
|
10002: 'Relay List',
|
|
10019: 'Wallet Info',
|
|
10123: 'Skill Adoption',
|
|
17375: 'Cashu Wallet',
|
|
30023: 'Long-form Article',
|
|
30024: 'Draft Article',
|
|
30078: 'App-specific Data',
|
|
31123: 'Skill (Public)',
|
|
31124: 'Skill (Private)',
|
|
7375: 'Cashu Token',
|
|
7376: 'Cashu History'
|
|
};
|
|
|
|
let updateIntervalId = null;
|
|
let currentPubkey = null;
|
|
let hamburgerInstance = null;
|
|
let isNavOpen = false;
|
|
let logoutHamburger = null;
|
|
let themeToggleHamburger = null;
|
|
let isDarkMode = false;
|
|
|
|
let db = null;
|
|
const DB_NAME = 'ndk-shared';
|
|
let selectedKind = null;
|
|
let selectedEventKey = null;
|
|
let selectedKindSub = null;
|
|
let currentDetailEvent = null;
|
|
|
|
const eventsByKind = new Map(); // kind -> Map(eventKey, event)
|
|
const selectedEventKeys = new Set();
|
|
const eventRelayPresence = new Map(); // eventKey -> Map(relayUrl, boolean)
|
|
const eventRelayPublishState = new Map(); // eventKey -> Map(relayUrl, 'idle'|'checking'|'publishing'|'failed')
|
|
let subscribedRelayUrls = [];
|
|
let refreshKindDebugInFlight = false;
|
|
const autoRefreshedKinds = new Set();
|
|
const BRAILLE_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
let brailleSpinnerFrameIndex = 0;
|
|
let brailleSpinnerTimerId = null;
|
|
|
|
const divBody = document.getElementById('divBody');
|
|
const divSideNav = document.getElementById('divSideNav');
|
|
const divFooterCenter = document.getElementById('divFooterCenter');
|
|
const divFooterRight = document.getElementById('divFooterRight');
|
|
|
|
const btnRefreshKinds = document.getElementById('btnRefreshKinds');
|
|
const divKindsStatus = document.getElementById('divKindsStatus');
|
|
const divKindsList = document.getElementById('divKindsList');
|
|
|
|
const inpEventSearch = document.getElementById('inpEventSearch');
|
|
const chkSelectAllEvents = document.getElementById('chkSelectAllEvents');
|
|
const chkShowSuperseded = document.getElementById('chkShowSuperseded');
|
|
const divEventsCount = document.getElementById('divEventsCount');
|
|
const divEventsList = document.getElementById('divEventsList');
|
|
const btnPublishSelectedMissing = document.getElementById('btnPublishSelectedMissing');
|
|
|
|
const divDetailMeta = document.getElementById('divDetailMeta');
|
|
const preEventJson = document.getElementById('preEventJson');
|
|
const btnCopyEventId = document.getElementById('btnCopyEventId');
|
|
const btnCopyEventJson = document.getElementById('btnCopyEventJson');
|
|
const btnDeleteEvent = document.getElementById('btnDeleteEvent');
|
|
const btnBlockEvent = document.getElementById('btnBlockEvent');
|
|
const btnRemoveEventFromCache = document.getElementById('btnRemoveEventFromCache');
|
|
const divDecryptMeta = document.getElementById('divDecryptMeta');
|
|
const preDecryptedContent = document.getElementById('preDecryptedContent');
|
|
|
|
function setDetailActionButtonsEnabled(enabled) {
|
|
const disabled = !Boolean(enabled);
|
|
[btnCopyEventId, btnCopyEventJson, btnDeleteEvent, btnBlockEvent, btnRemoveEventFromCache]
|
|
.filter(Boolean)
|
|
.forEach((btn) => {
|
|
btn.disabled = disabled;
|
|
});
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? '')
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"');
|
|
}
|
|
|
|
function shortHex(value = '') {
|
|
const s = String(value || '');
|
|
if (s.length < 16) return s;
|
|
return `${s.slice(0, 10)}…${s.slice(-8)}`;
|
|
}
|
|
|
|
function kindLabel(kind) {
|
|
const n = Number(kind);
|
|
return KIND_LABELS[n] || 'Unknown Kind';
|
|
}
|
|
|
|
function normalizeDisplayId(id, kind) {
|
|
const rawId = String(id ?? '');
|
|
const prefix = `${kind}:`;
|
|
if (rawId.startsWith(prefix)) return rawId.slice(prefix.length);
|
|
return rawId;
|
|
}
|
|
|
|
function getDTag(tags) {
|
|
const t = (Array.isArray(tags) ? tags : []).find(x => Array.isArray(x) && x[0] === 'd');
|
|
return t && t[1] ? String(t[1]) : '';
|
|
}
|
|
|
|
function eventKeyFor(evt) {
|
|
return `${Number(evt.kind)}:${String(evt.id || '')}`;
|
|
}
|
|
|
|
function setDecryptedOutput(text, meta = 'Decrypted content will appear here.') {
|
|
if (divDecryptMeta) divDecryptMeta.textContent = String(meta || 'Decrypted content will appear here.');
|
|
if (preDecryptedContent) preDecryptedContent.textContent = String(text || '(none)');
|
|
}
|
|
|
|
function isLikelyNip04Content(content) {
|
|
return typeof content === 'string' && content.includes('?iv=');
|
|
}
|
|
|
|
function isLikelyEncryptedEvent(evt) {
|
|
if (!evt) return false;
|
|
const kind = Number(evt.kind || 0);
|
|
const content = String(evt.content || '').trim();
|
|
if (!content) return false;
|
|
if (kind === 4 || kind === 44 || kind === 30078) return true;
|
|
if (isLikelyNip04Content(content)) return true;
|
|
if (content.startsWith('nip44v2:')) return true;
|
|
return false;
|
|
}
|
|
|
|
function getTagValue(tags, name) {
|
|
const arr = Array.isArray(tags) ? tags : [];
|
|
const row = arr.find((t) => Array.isArray(t) && String(t[0] || '') === String(name || ''));
|
|
return row && row[1] ? String(row[1]) : '';
|
|
}
|
|
|
|
function resolveDecryptPeerPubkey(evt) {
|
|
const eventObj = evt || {};
|
|
const authorPubkey = String(eventObj.pubkey || '');
|
|
const pTagPubkey = getTagValue(eventObj.tags, 'p');
|
|
const kind = Number(eventObj.kind || 0);
|
|
const authoredByMe = Boolean(currentPubkey && authorPubkey && currentPubkey === authorPubkey);
|
|
|
|
if (kind === 4 || kind === 44) {
|
|
if (authoredByMe) return pTagPubkey || authorPubkey || String(currentPubkey || '');
|
|
return authorPubkey || pTagPubkey || String(currentPubkey || '');
|
|
}
|
|
|
|
return pTagPubkey || authorPubkey || String(currentPubkey || '');
|
|
}
|
|
|
|
async function decryptCurrentDetailContent() {
|
|
if (!currentDetailEvent?.id) throw new Error('No selected event');
|
|
|
|
const eventObj = currentDetailEvent;
|
|
const kind = Number(eventObj.kind || 0);
|
|
const ciphertext = String(eventObj.content || '');
|
|
if (!ciphertext) throw new Error('Event content is empty');
|
|
|
|
const peerPubkey = resolveDecryptPeerPubkey(eventObj);
|
|
if (!peerPubkey) throw new Error('Could not determine decrypt peer pubkey');
|
|
|
|
const likelyNip04 = kind === 4 || isLikelyNip04Content(ciphertext);
|
|
const likelyNip44 = kind === 44 || kind === 30078 || !likelyNip04;
|
|
|
|
const errors = [];
|
|
const tryNip04First = likelyNip04 && !likelyNip44;
|
|
const methods = tryNip04First ? ['nip04', 'nip44'] : ['nip44', 'nip04'];
|
|
|
|
for (const method of methods) {
|
|
try {
|
|
if (method === 'nip44') {
|
|
if (!window?.nostr?.nip44?.decrypt) throw new Error('NIP-44 decrypt unavailable');
|
|
const plaintext = await window.nostr.nip44.decrypt(peerPubkey, ciphertext);
|
|
return { plaintext: String(plaintext || ''), method: 'NIP-44', peerPubkey };
|
|
}
|
|
|
|
if (!window?.nostr?.nip04?.decrypt) throw new Error('NIP-04 decrypt unavailable');
|
|
const plaintext = await window.nostr.nip04.decrypt(peerPubkey, ciphertext);
|
|
return { plaintext: String(plaintext || ''), method: 'NIP-04', peerPubkey };
|
|
} catch (error) {
|
|
errors.push(`${method}: ${error?.message || String(error)}`);
|
|
}
|
|
}
|
|
|
|
throw new Error(`Decrypt failed (${errors.join(' | ')})`);
|
|
}
|
|
|
|
let decryptAttemptSequence = 0;
|
|
|
|
async function autoDecryptDetailEvent(detailEvent, sequence) {
|
|
if (!detailEvent?.id) return;
|
|
|
|
try {
|
|
const result = await decryptCurrentDetailContent();
|
|
|
|
if (sequence !== decryptAttemptSequence) return;
|
|
if (!currentDetailEvent || eventKeyFor(currentDetailEvent) !== eventKeyFor(detailEvent)) return;
|
|
|
|
const prettyPlain = (() => {
|
|
const raw = String(result.plaintext || '');
|
|
try {
|
|
return JSON.stringify(JSON.parse(raw), null, 2);
|
|
} catch (_error) {
|
|
return raw;
|
|
}
|
|
})();
|
|
|
|
setDecryptedOutput(
|
|
prettyPlain,
|
|
`Decrypted via ${result.method} using peer ${shortHex(result.peerPubkey)}`
|
|
);
|
|
} catch (error) {
|
|
if (sequence !== decryptAttemptSequence) return;
|
|
if (!currentDetailEvent || eventKeyFor(currentDetailEvent) !== eventKeyFor(detailEvent)) return;
|
|
setDecryptedOutput('(decrypt failed)', `Decrypt failed: ${error?.message || String(error)}`);
|
|
}
|
|
}
|
|
|
|
function setSubscribedRelayUrlsFromRelayData(relayData) {
|
|
const rows = Array.isArray(relayData) ? relayData : [];
|
|
const hasRelayListScopedRows = rows.some((r) => r?.fromRelayList === true);
|
|
const scopedRows = hasRelayListScopedRows
|
|
? rows.filter((r) => r?.fromRelayList === true)
|
|
: rows;
|
|
|
|
const urls = [...new Set(scopedRows
|
|
.map((r) => String(r?.url || '').trim())
|
|
.filter(Boolean))];
|
|
|
|
subscribedRelayUrls = urls;
|
|
|
|
for (const relayMap of eventRelayPresence.values()) {
|
|
for (const url of subscribedRelayUrls) {
|
|
if (!relayMap.has(url)) relayMap.set(url, false);
|
|
}
|
|
}
|
|
|
|
for (const relayStateMap of eventRelayPublishState.values()) {
|
|
for (const url of subscribedRelayUrls) {
|
|
if (!relayStateMap.has(url)) relayStateMap.set(url, 'idle');
|
|
}
|
|
}
|
|
}
|
|
|
|
function ensureRelayMapForEvent(eventKey) {
|
|
let relayMap = eventRelayPresence.get(eventKey);
|
|
if (!relayMap) {
|
|
relayMap = new Map();
|
|
for (const url of subscribedRelayUrls) relayMap.set(url, false);
|
|
eventRelayPresence.set(eventKey, relayMap);
|
|
} else {
|
|
for (const url of subscribedRelayUrls) {
|
|
if (!relayMap.has(url)) relayMap.set(url, false);
|
|
}
|
|
}
|
|
return relayMap;
|
|
}
|
|
|
|
function ensureRelayPublishStateForEvent(eventKey) {
|
|
let relayStateMap = eventRelayPublishState.get(eventKey);
|
|
if (!relayStateMap) {
|
|
relayStateMap = new Map();
|
|
for (const url of subscribedRelayUrls) relayStateMap.set(url, 'idle');
|
|
eventRelayPublishState.set(eventKey, relayStateMap);
|
|
} else {
|
|
for (const url of subscribedRelayUrls) {
|
|
if (!relayStateMap.has(url)) relayStateMap.set(url, 'idle');
|
|
}
|
|
}
|
|
return relayStateMap;
|
|
}
|
|
|
|
function markRelayPublishState(eventKey, relayUrl, state) {
|
|
if (!eventKey || !relayUrl) return;
|
|
const relayStateMap = ensureRelayPublishStateForEvent(eventKey);
|
|
relayStateMap.set(String(relayUrl), String(state || 'idle'));
|
|
}
|
|
|
|
function markRelayPresence(eventKey, relayUrl, present) {
|
|
if (!eventKey || !relayUrl) return;
|
|
const relayMap = ensureRelayMapForEvent(eventKey);
|
|
const presentBool = Boolean(present);
|
|
const relayUrlStr = String(relayUrl);
|
|
relayMap.set(relayUrlStr, presentBool);
|
|
if (presentBool) markRelayPublishState(eventKey, relayUrlStr, 'idle');
|
|
}
|
|
|
|
function shortRelayLabel(url) {
|
|
return String(url || '')
|
|
.replace(/^wss?:\/\//i, '')
|
|
.replace(/\/$/, '');
|
|
}
|
|
|
|
function getBrailleSpinnerFrame() {
|
|
return BRAILLE_SPINNER_FRAMES[brailleSpinnerFrameIndex % BRAILLE_SPINNER_FRAMES.length];
|
|
}
|
|
|
|
function hasAnyCheckingRelayState() {
|
|
for (const relayStateMap of eventRelayPublishState.values()) {
|
|
for (const state of relayStateMap.values()) {
|
|
if (String(state) === 'checking') return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function syncBrailleSpinnerAnimation() {
|
|
const needsAnimation = hasAnyCheckingRelayState();
|
|
|
|
if (needsAnimation && !brailleSpinnerTimerId) {
|
|
brailleSpinnerTimerId = setInterval(() => {
|
|
brailleSpinnerFrameIndex = (brailleSpinnerFrameIndex + 1) % BRAILLE_SPINNER_FRAMES.length;
|
|
renderEvents();
|
|
}, 120);
|
|
} else if (!needsAnimation && brailleSpinnerTimerId) {
|
|
clearInterval(brailleSpinnerTimerId);
|
|
brailleSpinnerTimerId = null;
|
|
}
|
|
}
|
|
|
|
function renderRelayPresenceChips(eventKey) {
|
|
if (!subscribedRelayUrls.length) return '';
|
|
const relayMap = ensureRelayMapForEvent(eventKey);
|
|
const relayStateMap = ensureRelayPublishStateForEvent(eventKey);
|
|
const chips = subscribedRelayUrls.map((url) => {
|
|
const present = Boolean(relayMap.get(url));
|
|
const state = String(relayStateMap.get(url) || 'idle');
|
|
|
|
const stateLabel = state === 'checking'
|
|
? `${getBrailleSpinnerFrame()} checking`
|
|
: (state === 'publishing' ? 'posting...' : (state === 'failed' ? 'failed' : (present ? 'yes' : 'no')));
|
|
|
|
const stateClass = state === 'checking'
|
|
? 'checking'
|
|
: (state === 'publishing' ? 'publishing' : (state === 'failed' ? 'failed' : (present ? 'yes' : 'no')));
|
|
|
|
const clickable = !present && state !== 'publishing' && state !== 'checking';
|
|
|
|
return `<button class="relayChip ${stateClass}" type="button" data-relay-chip="1" data-event-key="${escapeHtml(eventKey)}" data-relay-url="${escapeHtml(url)}" ${clickable ? '' : 'disabled'}>${escapeHtml(shortRelayLabel(url))}: ${stateLabel}</button>`;
|
|
}).join('');
|
|
return `<div class="relayPresence">${chips}</div>`;
|
|
}
|
|
|
|
function upsertEvent(evt) {
|
|
if (!evt || !evt.id || !Number.isFinite(Number(evt.kind))) return;
|
|
const kind = Number(evt.kind);
|
|
if (!eventsByKind.has(kind)) eventsByKind.set(kind, new Map());
|
|
|
|
const byId = eventsByKind.get(kind);
|
|
const key = eventKeyFor(evt);
|
|
const prev = byId.get(key);
|
|
if (!prev || Number(evt.created_at || 0) >= Number(prev.created_at || 0)) {
|
|
const merged = {
|
|
...(prev || {}),
|
|
...evt,
|
|
sig: String(evt?.sig || prev?.sig || '')
|
|
};
|
|
byId.set(key, merged);
|
|
}
|
|
|
|
ensureRelayMapForEvent(key);
|
|
ensureRelayPublishStateForEvent(key);
|
|
if (evt.relay) {
|
|
markRelayPresence(key, String(evt.relay), true);
|
|
}
|
|
}
|
|
|
|
async function openDatabase() {
|
|
return new Promise((resolve, reject) => {
|
|
const request = indexedDB.open(DB_NAME);
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => resolve(request.result);
|
|
});
|
|
}
|
|
|
|
function resolveEventsStoreName(database) {
|
|
const availableStores = Array.from(database.objectStoreNames);
|
|
const possibleStores = ['events', 'Event', 'ndk_events', 'cache', 'nostr_events'];
|
|
const storeName = possibleStores.find((s) => availableStores.includes(s)) || availableStores[0];
|
|
if (!storeName) throw new Error('No object stores found in database');
|
|
return storeName;
|
|
}
|
|
|
|
async function getAllEvents() {
|
|
if (!db) db = await openDatabase();
|
|
const storeName = resolveEventsStoreName(db);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction([storeName], 'readonly');
|
|
const store = tx.objectStore(storeName);
|
|
const req = store.getAll();
|
|
req.onerror = () => reject(req.error);
|
|
req.onsuccess = () => resolve(req.result || []);
|
|
});
|
|
}
|
|
|
|
async function removeEventFromCache(evt) {
|
|
if (!evt || !evt.id || !Number.isFinite(Number(evt.kind))) return 0;
|
|
if (!db) db = await openDatabase();
|
|
|
|
const targetId = String(evt.id || '');
|
|
const targetKind = Number(evt.kind);
|
|
const targetPubkey = String(evt.pubkey || '');
|
|
const storeName = resolveEventsStoreName(db);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction([storeName], 'readwrite');
|
|
const store = tx.objectStore(storeName);
|
|
const req = store.openCursor();
|
|
let removed = 0;
|
|
|
|
req.onerror = () => reject(req.error);
|
|
|
|
req.onsuccess = () => {
|
|
const cursor = req.result;
|
|
if (!cursor) {
|
|
resolve(removed);
|
|
return;
|
|
}
|
|
|
|
const row = cursor.value || {};
|
|
let rowKind = Number(row.kind || 0);
|
|
let rowId = normalizeDisplayId(row.id, rowKind);
|
|
let rowPubkey = String(row.pubkey || '');
|
|
|
|
try {
|
|
const parsed = JSON.parse(row.event);
|
|
const parsedKind = Array.isArray(parsed) ? parsed[0] : parsed.kind;
|
|
const parsedId = Array.isArray(parsed) ? parsed[6] : parsed.id;
|
|
rowKind = Number(row.kind ?? parsedKind);
|
|
rowId = normalizeDisplayId(parsedId ?? row.id, rowKind);
|
|
rowPubkey = String(row.pubkey || '');
|
|
} catch (_error) {
|
|
// keep fallback values
|
|
}
|
|
|
|
const kindMatch = rowKind === targetKind;
|
|
const idMatch = String(rowId || '') === targetId;
|
|
const pubkeyMatch = !targetPubkey || rowPubkey === targetPubkey;
|
|
|
|
if (kindMatch && idMatch && pubkeyMatch) {
|
|
removed += 1;
|
|
cursor.delete();
|
|
}
|
|
|
|
cursor.continue();
|
|
};
|
|
});
|
|
}
|
|
|
|
function parseCachedEventRow(row) {
|
|
try {
|
|
const parsed = JSON.parse(row.event);
|
|
const parsedKind = Array.isArray(parsed) ? parsed[0] : parsed.kind;
|
|
const parsedId = Array.isArray(parsed) ? parsed[6] : parsed.id;
|
|
const kind = Number(row.kind ?? parsedKind);
|
|
const content = Array.isArray(parsed) ? (parsed[5] || '') : (parsed.content || '');
|
|
const tags = Array.isArray(parsed) ? (parsed[4] || []) : (parsed.tags || []);
|
|
const sig = Array.isArray(parsed) ? parsed[7] : parsed.sig;
|
|
return {
|
|
id: normalizeDisplayId(parsedId ?? row.id, kind),
|
|
kind,
|
|
pubkey: String(row.pubkey || ''),
|
|
created_at: Number(row.createdAt ?? row.created_at ?? 0),
|
|
content: String(content || ''),
|
|
tags: Array.isArray(tags) ? tags : [],
|
|
sig: String(sig || row.sig || ''),
|
|
relay: String(row.relay || ''),
|
|
raw_event: row.event
|
|
};
|
|
} catch (_error) {
|
|
const kind = Number(row.kind || 0);
|
|
return {
|
|
id: normalizeDisplayId(row.id, kind),
|
|
kind,
|
|
pubkey: String(row.pubkey || ''),
|
|
created_at: Number(row.createdAt ?? row.created_at ?? 0),
|
|
content: 'PARSE ERROR',
|
|
tags: [],
|
|
sig: String(row.sig || ''),
|
|
relay: String(row.relay || ''),
|
|
raw_event: row.event
|
|
};
|
|
}
|
|
}
|
|
|
|
function getKindsSorted() {
|
|
return Array.from(eventsByKind.keys()).sort((a, b) => a - b);
|
|
}
|
|
|
|
function getEventsForKind(kind) {
|
|
const map = eventsByKind.get(Number(kind));
|
|
if (!map) return [];
|
|
return Array.from(map.values()).sort((a, b) => {
|
|
const ta = Number(a.created_at || 0);
|
|
const tb = Number(b.created_at || 0);
|
|
if (tb !== ta) return tb - ta;
|
|
return String(a.id || '').localeCompare(String(b.id || ''));
|
|
});
|
|
}
|
|
|
|
function isReplaceableKind(kind) {
|
|
const k = Number(kind);
|
|
return k === 0 || k === 3 || (k >= 10000 && k < 20000);
|
|
}
|
|
|
|
function isParameterizedReplaceableKind(kind) {
|
|
const k = Number(kind);
|
|
return k >= 30000 && k < 40000;
|
|
}
|
|
|
|
function getReplaceableGroupKey(evt) {
|
|
if (!evt || !Number.isFinite(Number(evt.kind))) return null;
|
|
const kind = Number(evt.kind);
|
|
|
|
if (isParameterizedReplaceableKind(kind)) {
|
|
return `p:${kind}:${getDTag(evt.tags)}`;
|
|
}
|
|
|
|
if (isReplaceableKind(kind)) {
|
|
return `r:${kind}`;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getSupersededEventKeys(events) {
|
|
const latestByGroup = new Map();
|
|
const superseded = new Set();
|
|
|
|
for (const evt of (Array.isArray(events) ? events : [])) {
|
|
const groupKey = getReplaceableGroupKey(evt);
|
|
if (!groupKey) continue;
|
|
|
|
const candidateTs = Number(evt.created_at || 0);
|
|
const candidateId = String(evt.id || '');
|
|
const prev = latestByGroup.get(groupKey);
|
|
|
|
if (!prev) {
|
|
latestByGroup.set(groupKey, evt);
|
|
continue;
|
|
}
|
|
|
|
const prevTs = Number(prev.created_at || 0);
|
|
const prevId = String(prev.id || '');
|
|
|
|
const candidateNewer = candidateTs > prevTs || (candidateTs === prevTs && candidateId > prevId);
|
|
if (candidateNewer) {
|
|
superseded.add(eventKeyFor(prev));
|
|
latestByGroup.set(groupKey, evt);
|
|
} else {
|
|
superseded.add(eventKeyFor(evt));
|
|
}
|
|
}
|
|
|
|
return superseded;
|
|
}
|
|
|
|
function getFilteredEventsForSelectedKind() {
|
|
if (!Number.isFinite(Number(selectedKind))) {
|
|
return { visibleEvents: [], supersededKeys: new Set(), hiddenSupersededCount: 0 };
|
|
}
|
|
|
|
const all = getEventsForKind(selectedKind);
|
|
const supersededKeys = getSupersededEventKeys(all);
|
|
|
|
const includeSuperseded = Boolean(chkShowSuperseded?.checked);
|
|
const base = includeSuperseded
|
|
? all
|
|
: all.filter((evt) => !supersededKeys.has(eventKeyFor(evt)));
|
|
|
|
const q = String(inpEventSearch.value || '').trim().toLowerCase();
|
|
const visibleEvents = !q
|
|
? base
|
|
: base.filter((evt) => {
|
|
const dTag = getDTag(evt.tags);
|
|
const hay = `${evt.id || ''} ${dTag} ${evt.content || ''}`.toLowerCase();
|
|
return hay.includes(q);
|
|
});
|
|
|
|
const hiddenSupersededCount = includeSuperseded ? 0 : all.reduce((count, evt) => {
|
|
return count + (supersededKeys.has(eventKeyFor(evt)) ? 1 : 0);
|
|
}, 0);
|
|
|
|
return {
|
|
visibleEvents,
|
|
supersededKeys,
|
|
hiddenSupersededCount
|
|
};
|
|
}
|
|
|
|
function renderKinds() {
|
|
const kinds = getKindsSorted();
|
|
if (!kinds.length) {
|
|
divKindsList.innerHTML = '<div class="hint">No cached events found.</div>';
|
|
divKindsStatus.textContent = '0 kinds · 0 events';
|
|
return;
|
|
}
|
|
|
|
const totalEvents = kinds.reduce((sum, k) => sum + getEventsForKind(k).length, 0);
|
|
divKindsStatus.textContent = `${kinds.length} kinds · ${totalEvents.toLocaleString()} cached events`;
|
|
|
|
divKindsList.innerHTML = kinds.map((kind) => {
|
|
const count = getEventsForKind(kind).length;
|
|
const active = Number(selectedKind) === Number(kind) ? 'active' : '';
|
|
return `
|
|
<div class="kindItem ${active}" data-kind="${kind}">
|
|
<div class="rowTop">
|
|
<span>Kind ${escapeHtml(kind)} · ${escapeHtml(kindLabel(kind))}</span>
|
|
<span class="kindBadge">${count.toLocaleString()}</span>
|
|
</div>
|
|
<div class="rowMeta">Click to view events</div>
|
|
<div class="btnRow">
|
|
<button class="btnMini" type="button" data-refresh-kind="${kind}">Refresh Kind</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
Array.from(divKindsList.querySelectorAll('.kindItem[data-kind]')).forEach((el) => {
|
|
el.addEventListener('click', async () => {
|
|
await selectKind(Number(el.getAttribute('data-kind')));
|
|
});
|
|
});
|
|
|
|
Array.from(divKindsList.querySelectorAll('button[data-refresh-kind]')).forEach((btn) => {
|
|
btn.addEventListener('click', async (ev) => {
|
|
ev.stopPropagation();
|
|
const kind = Number(btn.getAttribute('data-refresh-kind'));
|
|
if (!Number.isFinite(kind)) return;
|
|
await refreshKindFromRelays(kind);
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderEvents() {
|
|
const { visibleEvents: events, supersededKeys, hiddenSupersededCount } = getFilteredEventsForSelectedKind();
|
|
divEventsCount.textContent = hiddenSupersededCount > 0
|
|
? `${events.length.toLocaleString()} visible · ${hiddenSupersededCount.toLocaleString()} superseded hidden`
|
|
: `${events.length.toLocaleString()} visible`;
|
|
|
|
if (!Number.isFinite(Number(selectedKind))) {
|
|
divEventsList.innerHTML = '<div class="hint">Select a kind from the left column.</div>';
|
|
chkSelectAllEvents.checked = false;
|
|
return;
|
|
}
|
|
|
|
if (!events.length) {
|
|
divEventsList.innerHTML = '<div class="hint">No events in this kind for current filter.</div>';
|
|
chkSelectAllEvents.checked = false;
|
|
return;
|
|
}
|
|
|
|
divEventsList.innerHTML = events.map((evt) => {
|
|
const key = eventKeyFor(evt);
|
|
const active = selectedEventKey === key ? 'active' : '';
|
|
const checked = selectedEventKeys.has(key) ? 'checked' : '';
|
|
const dTag = getDTag(evt.tags);
|
|
const preview = dTag || String(evt.content || '').replace(/\s+/g, ' ').slice(0, 110) || '(no content)';
|
|
const created = Number(evt.created_at || 0) > 0 ? new Date(Number(evt.created_at) * 1000).toLocaleString() : '-';
|
|
|
|
return `
|
|
<div class="eventItem ${active} ${supersededKeys.has(key) ? 'superseded' : ''}" data-key="${escapeHtml(key)}">
|
|
<div class="eventRow">
|
|
<input class="eventCheck" type="checkbox" data-check-key="${escapeHtml(key)}" ${checked} />
|
|
<div>
|
|
<div class="rowTop">
|
|
<span>${escapeHtml(shortHex(evt.id))}</span>
|
|
<span class="kindBadge ${supersededKeys.has(key) ? 'supersededBadge' : ''}">${escapeHtml(created)}</span>
|
|
</div>
|
|
<div class="rowMeta">${escapeHtml(preview)}</div>
|
|
${supersededKeys.has(key) ? '<div class="rowMeta">superseded by newer replaceable event</div>' : ''}
|
|
${renderRelayPresenceChips(key)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
const visibleKeys = new Set(events.map(eventKeyFor));
|
|
const allVisibleChecked = events.length > 0 && events.every((e) => selectedEventKeys.has(eventKeyFor(e)));
|
|
chkSelectAllEvents.checked = allVisibleChecked;
|
|
|
|
Array.from(divEventsList.querySelectorAll('.eventItem[data-key]')).forEach((el) => {
|
|
el.addEventListener('click', (ev) => {
|
|
if (ev.target && (ev.target.matches('input[type="checkbox"]') || ev.target.closest('[data-relay-chip]'))) return;
|
|
const key = String(el.getAttribute('data-key') || '');
|
|
const evt = findEventByKey(key);
|
|
if (!evt) return;
|
|
selectedEventKey = key;
|
|
renderEvents();
|
|
renderDetail(evt);
|
|
});
|
|
});
|
|
|
|
Array.from(divEventsList.querySelectorAll('input[data-check-key]')).forEach((box) => {
|
|
box.addEventListener('change', () => {
|
|
const key = String(box.getAttribute('data-check-key') || '');
|
|
if (!visibleKeys.has(key)) return;
|
|
if (box.checked) selectedEventKeys.add(key);
|
|
else selectedEventKeys.delete(key);
|
|
renderEvents();
|
|
});
|
|
});
|
|
}
|
|
|
|
function findEventByKey(key) {
|
|
const [kindRaw] = String(key || '').split(':');
|
|
const kind = Number(kindRaw);
|
|
if (!Number.isFinite(kind)) return null;
|
|
return eventsByKind.get(kind)?.get(key) || null;
|
|
}
|
|
|
|
function removeEventFromUiState(targetKind, targetEventId) {
|
|
if (!Number.isFinite(Number(targetKind)) || !targetEventId) return;
|
|
|
|
const kind = Number(targetKind);
|
|
const eventId = String(targetEventId);
|
|
const targetKey = `${kind}:${eventId}`;
|
|
|
|
const byKind = eventsByKind.get(kind);
|
|
if (byKind) {
|
|
byKind.delete(targetKey);
|
|
if (byKind.size === 0) eventsByKind.delete(kind);
|
|
}
|
|
|
|
selectedEventKeys.delete(targetKey);
|
|
eventRelayPresence.delete(targetKey);
|
|
eventRelayPublishState.delete(targetKey);
|
|
|
|
if (selectedEventKey === targetKey) {
|
|
selectedEventKey = null;
|
|
renderDetail(null);
|
|
}
|
|
|
|
renderKinds();
|
|
renderEvents();
|
|
}
|
|
|
|
function renderDetail(evt) {
|
|
if (!evt) {
|
|
decryptAttemptSequence += 1;
|
|
currentDetailEvent = null;
|
|
divDetailMeta.textContent = 'Select an event from the middle column.';
|
|
preEventJson.textContent = '{}';
|
|
setDecryptedOutput('(none)', 'Decrypted content will appear here.');
|
|
setDetailActionButtonsEnabled(false);
|
|
return;
|
|
}
|
|
|
|
const dTag = getDTag(evt.tags);
|
|
const created = Number(evt.created_at || 0) > 0 ? new Date(Number(evt.created_at) * 1000).toLocaleString() : '-';
|
|
divDetailMeta.textContent = `Kind ${evt.kind} · ${kindLabel(evt.kind)} · ${shortHex(evt.id)} · ${created}${dTag ? ` · d:${dTag}` : ''}`;
|
|
|
|
const pretty = {
|
|
id: evt.id,
|
|
kind: evt.kind,
|
|
pubkey: evt.pubkey,
|
|
created_at: evt.created_at,
|
|
tags: evt.tags,
|
|
content: evt.content,
|
|
sig: evt.sig
|
|
};
|
|
currentDetailEvent = pretty;
|
|
preEventJson.textContent = JSON.stringify(pretty, null, 2);
|
|
setDecryptedOutput('(none)', 'Decrypted content will appear here.');
|
|
setDetailActionButtonsEnabled(true);
|
|
|
|
const sequence = ++decryptAttemptSequence;
|
|
if (!isLikelyEncryptedEvent(pretty)) {
|
|
setDecryptedOutput('(none)', 'Content does not appear encrypted.');
|
|
return;
|
|
}
|
|
|
|
setDecryptedOutput('(decrypting...)', 'Detected encrypted content. Attempting automatic decryption...');
|
|
void autoDecryptDetailEvent(pretty, sequence);
|
|
}
|
|
|
|
function clearKindSubscription() {
|
|
try {
|
|
if (selectedKindSub && typeof selectedKindSub.close === 'function') selectedKindSub.close();
|
|
} catch (_error) { }
|
|
selectedKindSub = null;
|
|
}
|
|
|
|
function startKindSubscription(kind) {
|
|
clearKindSubscription();
|
|
if (!Number.isFinite(Number(kind)) || !currentPubkey) return;
|
|
selectedKindSub = subscribe({ kinds: [Number(kind)], authors: [currentPubkey], limit: 500 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
|
|
}
|
|
|
|
async function selectKind(kind, options = {}) {
|
|
const { autoRefresh = true } = options;
|
|
|
|
selectedKind = Number(kind);
|
|
selectedEventKey = null;
|
|
selectedEventKeys.clear();
|
|
renderKinds();
|
|
renderEvents();
|
|
renderDetail(null);
|
|
startKindSubscription(selectedKind);
|
|
|
|
const shouldAutoRefresh = autoRefresh
|
|
&& Number.isFinite(Number(selectedKind))
|
|
&& !autoRefreshedKinds.has(Number(selectedKind));
|
|
|
|
if (shouldAutoRefresh) {
|
|
await refreshKindFromRelays(selectedKind);
|
|
autoRefreshedKinds.add(Number(selectedKind));
|
|
}
|
|
}
|
|
|
|
async function loadKindsFromCache() {
|
|
const rows = await getAllEvents();
|
|
eventsByKind.clear();
|
|
eventRelayPresence.clear();
|
|
eventRelayPublishState.clear();
|
|
for (const row of rows) {
|
|
const evt = parseCachedEventRow(row);
|
|
if (!evt || !evt.id || !Number.isFinite(Number(evt.kind))) continue;
|
|
if (evt.pubkey && currentPubkey && evt.pubkey !== currentPubkey) continue;
|
|
upsertEvent(evt);
|
|
}
|
|
|
|
renderKinds();
|
|
renderEvents();
|
|
|
|
if (!Number.isFinite(Number(selectedKind))) {
|
|
const kinds = getKindsSorted();
|
|
if (kinds.length) await selectKind(kinds[0]);
|
|
}
|
|
}
|
|
|
|
async function refreshKindFromRelays(kind) {
|
|
if (!currentPubkey || !Number.isFinite(Number(kind))) return;
|
|
|
|
const targetKind = Number(kind);
|
|
const beforeCount = getEventsForKind(targetKind).length;
|
|
divKindsStatus.textContent = `Refreshing kind ${targetKind} from relays...`;
|
|
|
|
refreshKindDebugInFlight = true;
|
|
console.group(`[event-management] Refresh Kind ${targetKind}`);
|
|
console.log('[event-management] refresh:start', {
|
|
kind: targetKind,
|
|
pubkey: currentPubkey,
|
|
beforeCount
|
|
});
|
|
|
|
try {
|
|
const relayData = await getRelayData();
|
|
console.log('[event-management] refresh:getRelayData result', {
|
|
totalRows: Array.isArray(relayData) ? relayData.length : 0,
|
|
rows: relayData
|
|
});
|
|
|
|
setSubscribedRelayUrlsFromRelayData(relayData);
|
|
const relayUrls = [...subscribedRelayUrls];
|
|
console.log('[event-management] refresh:relayUrls scoped for query', relayUrls);
|
|
|
|
if (!relayUrls.length) {
|
|
divKindsStatus.textContent = `Kind ${targetKind}: no relays found in kind 10002 relay list`;
|
|
console.warn('[event-management] refresh:abort no relayUrls after scoping');
|
|
return;
|
|
}
|
|
|
|
let completedQueries = 0;
|
|
|
|
const queryResults = await Promise.all(relayUrls.map(async (relayUrl, index) => {
|
|
const relayKindEvents = getEventsForKind(targetKind);
|
|
const checkedKeys = new Set(relayKindEvents.map((evt) => eventKeyFor(evt)));
|
|
const seenKeys = new Set();
|
|
|
|
for (const evt of relayKindEvents) {
|
|
markRelayPublishState(eventKeyFor(evt), relayUrl, 'checking');
|
|
}
|
|
syncBrailleSpinnerAnimation();
|
|
if (Number(selectedKind) === targetKind) renderEvents();
|
|
|
|
divKindsStatus.textContent = `Refreshing kind ${targetKind}: querying relay ${index + 1}/${relayUrls.length} (${shortRelayLabel(relayUrl)})...`;
|
|
const startedAt = Date.now();
|
|
console.log('[event-management] refresh:query:start', {
|
|
index: index + 1,
|
|
total: relayUrls.length,
|
|
relayUrl,
|
|
filter: {
|
|
kinds: [targetKind],
|
|
authors: [currentPubkey],
|
|
limit: 2000
|
|
}
|
|
});
|
|
|
|
let acceptedForUser = 0;
|
|
let ok = false;
|
|
|
|
try {
|
|
const events = await fetchEvents({
|
|
kinds: [targetKind],
|
|
authors: [currentPubkey],
|
|
limit: 2000
|
|
}, relayUrl);
|
|
|
|
const fetchedEvents = Array.isArray(events) ? events : [];
|
|
|
|
for (const evt of fetchedEvents) {
|
|
if (!evt || !evt.id || !Number.isFinite(Number(evt.kind))) continue;
|
|
if (String(evt.pubkey || '') !== String(currentPubkey)) continue;
|
|
|
|
const normalizedKind = Number(evt.kind);
|
|
const normalized = {
|
|
id: normalizeDisplayId(String(evt.id || ''), normalizedKind),
|
|
kind: normalizedKind,
|
|
pubkey: String(evt.pubkey || ''),
|
|
created_at: Number(evt.created_at || 0),
|
|
content: String(evt.content || ''),
|
|
tags: Array.isArray(evt.tags) ? evt.tags : [],
|
|
sig: String(evt.sig || ''),
|
|
relay: String(relayUrl || '')
|
|
};
|
|
upsertEvent(normalized);
|
|
const eventKey = eventKeyFor(normalized);
|
|
seenKeys.add(eventKey);
|
|
markRelayPresence(eventKey, relayUrl, true);
|
|
acceptedForUser += 1;
|
|
}
|
|
|
|
console.log('[event-management] refresh:query:success', {
|
|
relayUrl,
|
|
fetchedCount: fetchedEvents.length,
|
|
acceptedForUser,
|
|
elapsedMs: Date.now() - startedAt
|
|
});
|
|
ok = true;
|
|
} catch (error) {
|
|
console.error('[event-management] refresh:query:error', {
|
|
relayUrl,
|
|
elapsedMs: Date.now() - startedAt,
|
|
error: error?.message || String(error)
|
|
});
|
|
} finally {
|
|
const relayKindEventsAfter = getEventsForKind(targetKind);
|
|
for (const evt of relayKindEventsAfter) {
|
|
const key = eventKeyFor(evt);
|
|
const wasCheckedBefore = checkedKeys.has(key);
|
|
const isPresentFromFetch = seenKeys.has(key);
|
|
|
|
if (wasCheckedBefore) {
|
|
markRelayPresence(key, relayUrl, isPresentFromFetch);
|
|
}
|
|
|
|
const relayMap = ensureRelayMapForEvent(key);
|
|
if (!relayMap.get(relayUrl)) markRelayPublishState(key, relayUrl, 'idle');
|
|
}
|
|
|
|
completedQueries += 1;
|
|
divKindsStatus.textContent = `Refreshing kind ${targetKind}: ${completedQueries}/${relayUrls.length} relays done...`;
|
|
syncBrailleSpinnerAnimation();
|
|
if (Number(selectedKind) === targetKind) renderEvents();
|
|
}
|
|
|
|
return {
|
|
ok,
|
|
relayUrl,
|
|
acceptedForUser
|
|
};
|
|
}));
|
|
|
|
const queried = relayUrls.length;
|
|
const successQueries = queryResults.reduce((n, r) => n + (r.ok ? 1 : 0), 0);
|
|
const failedQueries = queried - successQueries;
|
|
|
|
const afterCount = getEventsForKind(targetKind).length;
|
|
const added = Math.max(0, afterCount - beforeCount);
|
|
|
|
renderKinds();
|
|
if (Number(selectedKind) === targetKind) {
|
|
renderEvents();
|
|
if (selectedEventKey) {
|
|
const selected = findEventByKey(selectedEventKey);
|
|
if (selected) renderDetail(selected);
|
|
}
|
|
}
|
|
|
|
divKindsStatus.textContent = `Kind ${targetKind} refreshed from ${queried}/${relayUrls.length} relays (${successQueries} ok, ${failedQueries} failed) · +${added} events (${afterCount.toLocaleString()} total)`;
|
|
console.log('[event-management] refresh:done', {
|
|
kind: targetKind,
|
|
queried,
|
|
successQueries,
|
|
failedQueries,
|
|
beforeCount,
|
|
afterCount,
|
|
added
|
|
});
|
|
} catch (error) {
|
|
divKindsStatus.textContent = `Kind ${targetKind} refresh failed: ${error?.message || String(error)}`;
|
|
console.error('[event-management] refresh:fatal', {
|
|
kind: targetKind,
|
|
error: error?.message || String(error)
|
|
});
|
|
} finally {
|
|
refreshKindDebugInFlight = false;
|
|
syncBrailleSpinnerAnimation();
|
|
console.groupEnd();
|
|
}
|
|
}
|
|
|
|
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() {
|
|
if (isNavOpen) closeNav();
|
|
else openNav();
|
|
}
|
|
|
|
async function updateFooter() {
|
|
try {
|
|
await updateFooterRelayStatus();
|
|
await updateSidenavRelaySection();
|
|
await updateBlossomSection();
|
|
divFooterCenter.innerHTML = '';
|
|
divFooterRight.innerHTML = '';
|
|
} catch (error) {
|
|
console.error('[event-management.html] footer update error:', error);
|
|
}
|
|
}
|
|
|
|
async function logout() {
|
|
if (updateIntervalId) {
|
|
clearInterval(updateIntervalId);
|
|
updateIntervalId = null;
|
|
}
|
|
clearKindSubscription();
|
|
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 dbInfo of databases) if (dbInfo.name) window.indexedDB.deleteDatabase(dbInfo.name);
|
|
}
|
|
location.reload(true);
|
|
}
|
|
|
|
function wireEvents() {
|
|
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('[event-management.html] logout failed', error); }
|
|
});
|
|
|
|
btnRefreshKinds.addEventListener('click', async () => {
|
|
try {
|
|
divKindsStatus.textContent = 'Refreshing cache...';
|
|
await loadKindsFromCache();
|
|
} catch (error) {
|
|
divKindsStatus.textContent = `Refresh failed: ${error?.message || String(error)}`;
|
|
}
|
|
});
|
|
|
|
inpEventSearch.addEventListener('input', renderEvents);
|
|
chkShowSuperseded?.addEventListener('change', renderEvents);
|
|
|
|
chkSelectAllEvents.addEventListener('change', () => {
|
|
const { visibleEvents } = getFilteredEventsForSelectedKind();
|
|
if (chkSelectAllEvents.checked) {
|
|
for (const evt of visibleEvents) selectedEventKeys.add(eventKeyFor(evt));
|
|
} else {
|
|
for (const evt of visibleEvents) selectedEventKeys.delete(eventKeyFor(evt));
|
|
}
|
|
renderEvents();
|
|
});
|
|
|
|
btnCopyEventId?.addEventListener('click', async () => {
|
|
if (!currentDetailEvent?.id) return;
|
|
try {
|
|
await navigator.clipboard.writeText(String(currentDetailEvent.id));
|
|
divKindsStatus.textContent = 'Copied event ID to clipboard';
|
|
} catch (_error) {
|
|
divKindsStatus.textContent = 'Copy event ID failed';
|
|
}
|
|
});
|
|
|
|
btnCopyEventJson?.addEventListener('click', async () => {
|
|
if (!currentDetailEvent) return;
|
|
try {
|
|
await navigator.clipboard.writeText(JSON.stringify(currentDetailEvent, null, 2));
|
|
divKindsStatus.textContent = 'Copied event JSON to clipboard';
|
|
} catch (_error) {
|
|
divKindsStatus.textContent = 'Copy event JSON failed';
|
|
}
|
|
});
|
|
|
|
btnDeleteEvent?.addEventListener('click', async () => {
|
|
if (!currentDetailEvent?.id) return;
|
|
|
|
const ok = window.confirm(`Publish deletion event for ${shortHex(String(currentDetailEvent.id || ''))}?`);
|
|
if (!ok) return;
|
|
|
|
try {
|
|
const deletionEvent = {
|
|
kind: 5,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [
|
|
['e', String(currentDetailEvent.id || '')],
|
|
['k', String(Number(currentDetailEvent.kind || 0))]
|
|
],
|
|
content: ''
|
|
};
|
|
|
|
await publishEvent(deletionEvent);
|
|
const deletedEventId = String(currentDetailEvent.id || '');
|
|
const deletedEventKind = Number(currentDetailEvent.kind || 0);
|
|
removeEventFromUiState(deletedEventKind, deletedEventId);
|
|
divKindsStatus.textContent = `Published deletion request for ${shortHex(deletedEventId)}`;
|
|
} catch (error) {
|
|
divKindsStatus.textContent = `Delete event failed: ${error?.message || String(error)}`;
|
|
}
|
|
});
|
|
|
|
btnBlockEvent?.addEventListener('click', async () => {
|
|
if (!currentDetailEvent?.id) return;
|
|
|
|
const targetEventId = String(currentDetailEvent.id || '');
|
|
const ok = window.confirm(`Add event ${shortHex(targetEventId)} to mute/block list?`);
|
|
if (!ok) return;
|
|
|
|
try {
|
|
await addMute('e', targetEventId, false);
|
|
divKindsStatus.textContent = `Blocked event ${shortHex(targetEventId)} via mute list`;
|
|
} catch (error) {
|
|
divKindsStatus.textContent = `Block event failed: ${error?.message || String(error)}`;
|
|
}
|
|
});
|
|
|
|
btnRemoveEventFromCache?.addEventListener('click', async () => {
|
|
if (!currentDetailEvent?.id || !Number.isFinite(Number(currentDetailEvent?.kind))) return;
|
|
|
|
const targetEventId = String(currentDetailEvent.id || '');
|
|
const targetKind = Number(currentDetailEvent.kind || 0);
|
|
const ok = window.confirm(`Remove event ${shortHex(targetEventId)} (kind ${targetKind}) from cache?`);
|
|
if (!ok) return;
|
|
|
|
try {
|
|
const removed = await removeEventFromCache(currentDetailEvent);
|
|
removeEventFromUiState(targetKind, targetEventId);
|
|
divKindsStatus.textContent = `Removed ${removed} cached row(s) for ${shortHex(targetEventId)}`;
|
|
} catch (error) {
|
|
divKindsStatus.textContent = `Remove from cache failed: ${error?.message || String(error)}`;
|
|
}
|
|
});
|
|
|
|
btnPublishSelectedMissing?.addEventListener('click', async () => {
|
|
const keys = Array.from(selectedEventKeys);
|
|
if (!keys.length) {
|
|
divKindsStatus.textContent = 'No selected events';
|
|
return;
|
|
}
|
|
|
|
const jobs = [];
|
|
for (const key of keys) {
|
|
const evt = findEventByKey(key);
|
|
if (!evt) continue;
|
|
const relayMap = ensureRelayMapForEvent(key);
|
|
for (const relayUrl of subscribedRelayUrls) {
|
|
if (!relayMap.get(relayUrl)) {
|
|
jobs.push({ key, relayUrl, evt });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!jobs.length) {
|
|
divKindsStatus.textContent = 'Selected events already present on all relays';
|
|
return;
|
|
}
|
|
|
|
for (const job of jobs) markRelayPublishState(job.key, job.relayUrl, 'publishing');
|
|
renderEvents();
|
|
|
|
let successCount = 0;
|
|
let failedCount = 0;
|
|
let completed = 0;
|
|
|
|
await Promise.all(jobs.map(async (job) => {
|
|
try {
|
|
const payload = {
|
|
id: String(job.evt.id || ''),
|
|
pubkey: String(job.evt.pubkey || ''),
|
|
created_at: Number(job.evt.created_at || 0),
|
|
kind: Number(job.evt.kind),
|
|
tags: Array.isArray(job.evt.tags) ? job.evt.tags : [],
|
|
content: String(job.evt.content || ''),
|
|
sig: String(job.evt.sig || '')
|
|
};
|
|
|
|
const response = await publishRawEventToRelay(payload, job.relayUrl);
|
|
const successful = Array.isArray(response?.relayResults?.successful)
|
|
? response.relayResults.successful.map((x) => String(x || ''))
|
|
: [];
|
|
const accepted = successful.includes(job.relayUrl) || Boolean(response?.success);
|
|
|
|
if (accepted) {
|
|
markRelayPresence(job.key, job.relayUrl, true);
|
|
markRelayPublishState(job.key, job.relayUrl, 'idle');
|
|
successCount += 1;
|
|
} else {
|
|
markRelayPublishState(job.key, job.relayUrl, 'failed');
|
|
failedCount += 1;
|
|
}
|
|
} catch (_error) {
|
|
markRelayPublishState(job.key, job.relayUrl, 'failed');
|
|
failedCount += 1;
|
|
} finally {
|
|
completed += 1;
|
|
divKindsStatus.textContent = `Publishing selected events: ${completed}/${jobs.length} done (${successCount} ok, ${failedCount} failed)`;
|
|
renderEvents();
|
|
}
|
|
}));
|
|
|
|
divKindsStatus.textContent = `Publishing selected events done: ${successCount} ok, ${failedCount} failed`;
|
|
});
|
|
|
|
divEventsList.addEventListener('click', async (event) => {
|
|
const chip = event.target?.closest?.('[data-relay-chip]');
|
|
if (!chip) return;
|
|
|
|
const eventKey = String(chip.getAttribute('data-event-key') || '');
|
|
const relayUrl = String(chip.getAttribute('data-relay-url') || '');
|
|
if (!eventKey || !relayUrl) return;
|
|
|
|
const evt = findEventByKey(eventKey);
|
|
if (!evt) return;
|
|
|
|
const relayMap = ensureRelayMapForEvent(eventKey);
|
|
if (relayMap.get(relayUrl)) return;
|
|
|
|
const relayStateMap = ensureRelayPublishStateForEvent(eventKey);
|
|
if (relayStateMap.get(relayUrl) === 'publishing') return;
|
|
|
|
markRelayPublishState(eventKey, relayUrl, 'publishing');
|
|
renderEvents();
|
|
|
|
try {
|
|
const payload = {
|
|
id: String(evt.id || ''),
|
|
pubkey: String(evt.pubkey || ''),
|
|
created_at: Number(evt.created_at || 0),
|
|
kind: Number(evt.kind),
|
|
tags: Array.isArray(evt.tags) ? evt.tags : [],
|
|
content: String(evt.content || ''),
|
|
sig: String(evt.sig || '')
|
|
};
|
|
|
|
const response = await publishRawEventToRelay(payload, relayUrl);
|
|
const successful = Array.isArray(response?.relayResults?.successful)
|
|
? response.relayResults.successful.map((x) => String(x || ''))
|
|
: [];
|
|
|
|
const accepted = successful.includes(relayUrl) || Boolean(response?.success);
|
|
if (accepted) {
|
|
markRelayPresence(eventKey, relayUrl, true);
|
|
markRelayPublishState(eventKey, relayUrl, 'idle');
|
|
} else {
|
|
markRelayPublishState(eventKey, relayUrl, 'failed');
|
|
}
|
|
} catch (_error) {
|
|
markRelayPublishState(eventKey, relayUrl, 'failed');
|
|
}
|
|
|
|
renderEvents();
|
|
});
|
|
|
|
window.addEventListener('ndkEvent', (event) => {
|
|
const evt = event?.detail;
|
|
if (!evt || !Number.isFinite(Number(evt.kind)) || !evt.id) return;
|
|
if (evt.pubkey !== currentPubkey) return;
|
|
|
|
const parsedKind = Number(evt.kind);
|
|
const parsed = {
|
|
id: normalizeDisplayId(String(evt.id || ''), parsedKind),
|
|
kind: parsedKind,
|
|
pubkey: String(evt.pubkey || ''),
|
|
created_at: Number(evt.created_at || 0),
|
|
content: String(evt.content || ''),
|
|
tags: Array.isArray(evt.tags) ? evt.tags : [],
|
|
sig: String(evt.sig || ''),
|
|
relay: ''
|
|
};
|
|
|
|
upsertEvent(parsed);
|
|
renderKinds();
|
|
|
|
if (Number(selectedKind) === Number(parsed.kind)) {
|
|
renderEvents();
|
|
if (selectedEventKey === eventKeyFor(parsed)) renderDetail(parsed);
|
|
}
|
|
});
|
|
|
|
window.addEventListener('ndkRelayActivity', (event) => {
|
|
const { relayUrl, activity, stats } = event.detail || {};
|
|
if (relayUrl && activity) {
|
|
setRelayActivityState(relayUrl, activity);
|
|
if (refreshKindDebugInFlight) {
|
|
console.log('[event-management] ndkRelayActivity during refresh', {
|
|
relayUrl,
|
|
activity,
|
|
stats
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
try {
|
|
initHamburgerMenu();
|
|
wireEvents();
|
|
|
|
await initNDKPage();
|
|
currentPubkey = await getPubkey();
|
|
await injectHeaderAvatar(currentPubkey);
|
|
|
|
configureMuteList({
|
|
ndkFetchEvents,
|
|
queryCache,
|
|
publishEvent,
|
|
getPubkey,
|
|
nip44Encrypt: async (recipientPubkey, plaintext) => {
|
|
if (window?.nostr?.nip44?.encrypt) {
|
|
return await window.nostr.nip44.encrypt(recipientPubkey, plaintext);
|
|
}
|
|
if (window?.nostr?.nip04?.encrypt) {
|
|
return await window.nostr.nip04.encrypt(recipientPubkey, plaintext);
|
|
}
|
|
throw new Error('No encryption support available in signer');
|
|
},
|
|
nip44Decrypt: async (senderPubkey, ciphertext) => {
|
|
const content = String(ciphertext || '');
|
|
if (content.includes('?iv=')) {
|
|
if (!window?.nostr?.nip04?.decrypt) throw new Error('NIP-04 decrypt unavailable');
|
|
return await window.nostr.nip04.decrypt(senderPubkey, content);
|
|
}
|
|
if (window?.nostr?.nip44?.decrypt) {
|
|
return await window.nostr.nip44.decrypt(senderPubkey, content);
|
|
}
|
|
if (window?.nostr?.nip04?.decrypt) {
|
|
return await window.nostr.nip04.decrypt(senderPubkey, content);
|
|
}
|
|
throw new Error('No decryption support available in signer');
|
|
}
|
|
});
|
|
|
|
await loadMuteList().catch((error) => {
|
|
console.warn('[event-management] loadMuteList failed:', error?.message || error);
|
|
});
|
|
|
|
setSubscribedRelayUrlsFromRelayData(await getRelayData());
|
|
await loadKindsFromCache();
|
|
|
|
initFooterRelayStatus();
|
|
initSidenavRelaySection();
|
|
await initBlossomSection();
|
|
initAiSectionWithLocalConfig();
|
|
await updateFooter();
|
|
updateIntervalId = setInterval(updateFooter, 1000);
|
|
|
|
const sidenavWasOpen = localStorage.getItem('sidenavWasOpen');
|
|
if (sidenavWasOpen === 'true') {
|
|
localStorage.removeItem('sidenavWasOpen');
|
|
openNav();
|
|
}
|
|
|
|
await updateVersionDisplay();
|
|
} catch (error) {
|
|
console.error('[event-management.html] Initialization failed:', error);
|
|
divBody.innerHTML = `<div style="text-align:center;padding:40px;color:var(--accent-color);">${escapeHtml(error?.message || String(error))}</div>`;
|
|
}
|
|
}
|
|
|
|
main();
|
|
|