Files
client/build/.event-management-inline-check.mjs
2026-04-17 16:52:51 -04:00

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();