Files
client/www/event-management.html

2504 lines
87 KiB
HTML

<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>Event Management</title>
<link rel="stylesheet" href="./css/client.css" />
<link rel="stylesheet" href="./css/resizable-columns.css" />
<script>
(function () {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.documentElement.classList.add('dark-mode');
if (document.body) document.body.classList.add('dark-mode');
}
})();
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<script src="./js/vendor/svg.min.js"></script>
<style>
#divBody {
--event-col-1: 24%;
--event-col-2: 36%;
--event-col-3: 40%;
--event-body-gutter-size: 10px;
display: grid;
grid-template-columns:
var(--event-col-1)
var(--event-body-gutter-size)
var(--event-col-2)
var(--event-body-gutter-size)
var(--event-col-3);
gap: 0;
padding: 10px;
overflow: hidden;
align-items: stretch !important;
justify-content: stretch !important;
align-content: stretch !important;
flex-wrap: nowrap !important;
}
.panel {
border: 2px solid var(--primary-color);
border-radius: 10px;
background: var(--secondary-color);
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 10px;
gap: 8px;
}
.panelTitle {
font-size: 90%;
font-weight: bold;
color: var(--primary-color);
border-bottom: 1px solid var(--muted-color);
padding-bottom: 6px;
}
.inp {
border: 1px solid var(--muted-color);
border-radius: 6px;
background: var(--secondary-color);
color: var(--primary-color);
font-family: var(--font-family);
font-size: 80%;
padding: 7px;
width: 100%;
box-sizing: border-box;
}
.btnRow {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.btnMini {
border: 1px solid var(--muted-color);
border-radius: 6px;
background: transparent;
color: var(--primary-color);
cursor: pointer;
padding: 6px 10px;
font-size: 74%;
}
.btnMini:hover {
border-color: var(--accent-color);
color: var(--accent-color);
}
.hint {
font-size: 70%;
color: var(--primary-color);
}
.listWrap {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
padding-right: 2px;
}
.kindItem,
.eventItem {
border: 1px solid var(--muted-color);
border-radius: 8px;
padding: 7px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 4px;
user-select: none;
}
.kindItem:hover,
.eventItem:hover {
border-color: var(--accent-color);
color: var(--accent-color);
}
.kindItem.active,
.eventItem.active {
border-color: var(--primary-color);
box-shadow: 0 0 0 1px var(--primary-color) inset;
}
.rowTop {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center;
font-size: 78%;
font-weight: bold;
}
.rowMeta {
font-size: 70%;
color: var(--primary-color);
overflow-wrap: anywhere;
}
.kindBadge {
border: 1px solid var(--primary-color);
border-radius: 999px;
font-size: 68%;
padding: 2px 7px;
color: var(--primary-color);
white-space: nowrap;
}
.eventRow {
display: grid;
grid-template-columns: auto 1fr;
gap: 6px;
align-items: start;
}
.relayPresence {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 5px;
}
.relayChip {
border: 1px solid var(--primary-color);
border-radius: 999px;
padding: 2px 6px;
font-size: 64%;
white-space: nowrap;
color: var(--primary-color);
background: transparent;
}
.relayChip.no {
opacity: 0.75;
}
.relayChip.checking {
opacity: 0.95;
border-style: dashed;
cursor: wait;
}
.eventItem.superseded {
opacity: 0.6;
border-style: dashed;
}
.eventItem.muted {
opacity: 0.55;
border-color: var(--muted-color);
border-style: dotted;
}
.supersededBadge {
border-color: var(--muted-color);
color: var(--muted-color);
}
.mutedBadge {
border-color: var(--muted-color);
color: var(--muted-color);
background: color-mix(in srgb, var(--secondary-color) 88%, var(--muted-color) 12%);
}
.mutedMeta {
color: var(--muted-color);
font-style: italic;
}
.eventCheck {
margin-top: 2px;
accent-color: var(--primary-color);
}
#txtEventJson {
border: 1px solid var(--muted-color);
border-radius: 8px;
background: color-mix(in srgb, var(--secondary-color) 92%, var(--muted-color) 8%);
margin: 0;
padding: 10px;
min-height: 220px;
flex: 1;
overflow: auto;
resize: vertical;
box-sizing: border-box;
width: 100%;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
line-height: 1.45;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
color: var(--primary-color);
}
#txtDecryptedContent {
border: 1px solid var(--muted-color);
border-radius: 8px;
background: color-mix(in srgb, var(--secondary-color) 94%, var(--primary-color) 6%);
margin: 0;
padding: 10px;
min-height: 90px;
max-height: none;
overflow: auto;
resize: none;
box-sizing: border-box;
width: 100%;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
line-height: 1.45;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
color: var(--primary-color);
}
#eventDetailSplit {
--event-detail-row-1: 300px;
--event-detail-row-2: 140px;
--event-detail-gutter-size: 10px;
flex: 1;
min-height: 0;
display: grid;
grid-template-rows:
var(--event-detail-row-1)
var(--event-detail-gutter-size)
var(--event-detail-row-2);
overflow: hidden;
}
.eventDetailGutter {
width: 100%;
height: var(--event-detail-gutter-size);
cursor: row-resize;
}
.eventDetailEditor {
min-height: 0;
height: 100%;
max-height: none;
resize: none;
}
#txtDecryptedContent[readonly] {
opacity: 0.85;
background: color-mix(in srgb, var(--secondary-color) 96%, var(--muted-color) 4%);
cursor: default;
}
.eventBodyGutter {
width: var(--event-body-gutter-size);
height: 100%;
}
@media (max-width: 1300px) {
#divBody {
grid-template-columns: 1fr;
overflow-y: auto;
gap: 10px;
}
.eventBodyGutter {
display: none;
}
.panel {
min-height: 320px;
}
}
</style>
</head>
<body>
<div id="divSvgHam" class="divHeaderButtons"></div>
<div id="divHeader">
<div id="divHeaderFlexLeft"></div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText">Event Management</div>
</div>
<div id="divHeaderFlexRight"></div>
</div>
<div id="divBody">
<div class="panel" id="eventKindsPanel">
<div class="panelTitle">Kinds</div>
<div class="btnRow">
<button id="btnRefreshKinds" class="btnMini" type="button">Refresh Cache</button>
<button id="btnGetAllEvents" class="btnMini" type="button">Get all events</button>
</div>
<div id="divKindsStatus" class="hint">Loading...</div>
<div id="divKindsList" class="listWrap"></div>
</div>
<div class="eventBodyGutter resizableColumnsGutter" data-gutter-index="0" aria-hidden="true"></div>
<div class="panel" id="eventListPanel">
<div class="panelTitle">Events</div>
<input id="inpEventSearch" class="inp" type="text" placeholder="search id, d-tag, content..." />
<div class="btnRow">
<label class="hint" style="display:flex;align-items:center;gap:6px;">
<input id="chkSelectAllEvents" type="checkbox" /> Select all visible
</label>
<label class="hint" style="display:flex;align-items:center;gap:6px;">
<input id="chkShowSuperseded" type="checkbox" /> Show superseded
</label>
<button id="btnPublishSelectedMissing" class="btnMini" type="button">Publish Selected → Missing Relays</button>
<div id="divEventsCount" class="hint">0 visible</div>
</div>
<div id="divEventsList" class="listWrap"></div>
</div>
<div class="eventBodyGutter resizableColumnsGutter" data-gutter-index="1" aria-hidden="true"></div>
<div class="panel" id="eventDetailPanel">
<div class="panelTitle">Event Detail</div>
<div id="divDetailMeta" class="hint">Select an event from the middle column.</div>
<div class="btnRow">
<button id="btnCopyEventId" class="btnMini" type="button">Copy Event ID</button>
<button id="btnCopyEventJson" class="btnMini" type="button">Copy Event JSON</button>
<button id="btnEditRepostEvent" class="btnMini" type="button">Repost</button>
<button id="btnDeleteEvent" class="btnMini" type="button">Delete Event</button>
<button id="btnBlockEvent" class="btnMini" type="button">Block Event</button>
<button id="btnRemoveEventFromCache" class="btnMini" type="button">Remove From Cache</button>
<button id="btnDeleteBlockRemoveEvent" class="btnMini" type="button">Delete + Block + Remove</button>
</div>
<div id="eventDetailSplit">
<textarea id="txtEventJson" class="eventDetailEditor" spellcheck="false">{}</textarea>
<div class="eventDetailGutter resizableColumnsGutter" data-gutter-index="0" aria-hidden="true"></div>
<textarea id="txtDecryptedContent" class="eventDetailEditor" spellcheck="false" readonly>(none)</textarea>
</div>
<div id="divDecryptMeta" class="hint">Decrypted content will appear here.</div>
</div>
</div>
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox"></div>
<div id="divFooterRight" class="divFooterBox"></div>
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
</div>
<div id="divSideNav">
<div id="divSideNavHeader"></div>
<div id="divSideNavBody">
<div id="divFiles"></div>
</div>
<div id="divAiSection" class="sidenavSection">
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
<div id="divAiList" class="sidenavSectionList">
<div id="divAiProvidersList">No saved providers yet.</div>
</div>
</div>
<div id="divRelaySection">
<div id="divRelaySectionTitle">リレー</div>
<div id="divRelayList">Loading relays...</div>
</div>
<div id="divBlossomSection">
<div id="divBlossomSectionTitle">ブロッサム</div>
<div id="divBlossomList">Loading blossom servers...</div>
</div>
<div id="divVersionBar">
<span id="versionDisplay">v0.0.1</span>
<div id="divVersionBarButtons">
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
<div id="themeToggleHamburgerContainer"></div>
</button>
<button id="logoutButton" title="Logout">
<div id="logoutHamburgerContainer"></div>
</button>
</div>
</div>
</div>
<script src="./nostr.bundle.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {
initNDKPage,
getPubkey,
injectHeaderAvatar,
subscribe,
fetchEvents,
ndkFetchEvents,
queryCache,
getRelayData,
publishEvent,
publishRawEventToRelay,
disconnect,
getVersion,
updateVersionDisplay,
ensureMuteListLoaded,
addMute,
isEventMuted
} 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 { initResizableColumns } from './js/resizable-columns.mjs';
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
console.log(`[event-management.html ${VERSION}] Loading...`);
const KIND_LABELS = {
// Core / social
0: 'User Metadata',
1: 'Short Text Note',
2: 'Recommend Relay',
3: 'Follows',
4: 'Encrypted Direct Messages',
5: 'Event Deletion Request',
6: 'Repost',
7: 'Reaction',
8: 'Badge Award',
9: 'Chat Message',
10: 'Group Chat Threaded Reply',
11: 'Thread',
12: 'Group Thread Reply',
13: 'Seal',
14: 'Direct Message',
15: 'File Message',
16: 'Generic Repost',
17: 'Reaction to a website',
20: 'Picture',
21: 'Video Event',
22: 'Short-form Portrait Video Event',
24: 'Public Message',
30: 'Internal reference',
31: 'External web reference',
32: 'Hardcopy reference',
33: 'Prompt reference',
// Channels / communities
40: 'Channel Creation',
41: 'Channel Metadata',
42: 'Channel Message',
43: 'Channel Hide Message',
44: 'Channel Mute User',
4550: 'Community Post Approval',
34550: 'Community Definition',
// Misc protocol kinds
62: 'Request to Vanish',
64: 'Chess (PGN)',
443: 'KeyPackage',
444: 'Welcome Message',
445: 'Group Event',
818: 'Merge Requests',
1018: 'Poll Response',
1021: 'Bid',
1022: 'Bid confirmation',
1040: 'OpenTimestamps',
1059: 'Gift Wrap',
1063: 'File Metadata',
1068: 'Poll',
1111: 'Comment',
1222: 'Voice Message',
1244: 'Voice Message Comment',
1311: 'Live Chat Message',
1337: 'Code Snippet',
1617: 'Patches',
1618: 'Pull Requests',
1619: 'Pull Request Updates',
1621: 'Issues',
1622: 'Git Replies (deprecated)',
1971: 'Problem Tracker',
1984: 'Reporting',
1985: 'Label',
1986: 'Relay reviews',
1987: 'AI Embeddings / Vector lists',
2003: 'Torrent',
2004: 'Torrent Comment',
2022: 'Coinjoin Pool',
// Jobs / zaps / cashu
7000: 'Job Feedback',
7374: 'Reserved Cashu Wallet Tokens',
7375: 'Cashu Wallet Tokens',
7376: 'Cashu Wallet History',
7516: 'Geocache log',
7517: 'Geocache proof of find',
9041: 'Zap Goal',
9321: 'Nutzap',
9467: 'Tidal login',
9734: 'Zap Request',
9735: 'Zap',
9802: 'Highlights',
17375: 'Cashu Wallet Event',
38172: 'Cashu Mint Announcement',
38173: 'Fedimint Announcement',
// Relay / list / discovery
8000: 'Add User',
8001: 'Remove User',
10000: 'Mute list',
10001: 'Pin list',
10002: 'Relay List Metadata',
10003: 'Bookmark list',
10004: 'Communities list',
10005: 'Public chats list',
10006: 'Blocked relays list',
10007: 'Search relays list',
10009: 'User groups',
10011: 'External Identities',
10012: 'Favorite relays list',
10013: 'Private event relay list',
10015: 'Interests list',
10019: 'Nutzap Mint Recommendation',
10020: 'Media follows',
10030: 'User emoji list',
10050: 'Relay list to receive DMs',
10051: 'KeyPackage Relays List',
10063: 'User server list',
10096: 'File storage server list',
10166: 'Relay Monitor Announcement',
10312: 'Room Presence',
10377: 'Proxy Announcement',
11111: 'Transport Method Announcement',
13194: 'Wallet Info',
13534: 'Membership Lists',
14388: 'User Sound Effect Lists',
// Wallet/connect/auth
21000: 'Lightning Pub RPC',
22242: 'Client Authentication',
23194: 'Wallet Request',
23195: 'Wallet Response',
24133: 'Nostr Connect',
24242: 'Blobs stored on mediaservers',
27235: 'HTTP Auth',
28934: 'Join Request',
28935: 'Invite Request',
28936: 'Leave Request',
// Addressable/list/data kinds
30000: 'Follow sets',
30001: 'Generic lists',
30002: 'Relay sets',
30003: 'Bookmark sets',
30004: 'Curation sets',
30005: 'Video sets',
30006: 'Picture sets',
30007: 'Kind mute sets',
30008: 'Profile Badges',
30009: 'Badge Definition',
30015: 'Interest sets',
30017: 'Create or update a stall',
30018: 'Create or update a product',
30019: 'Marketplace UI/UX',
30020: 'Product sold as an auction',
30023: 'Long-form Content',
30024: 'Draft Long-form Content',
30030: 'Emoji sets',
30040: 'Curated Publication Index',
30041: 'Curated Publication Content',
30063: 'Release artifact sets',
30078: 'Application-specific Data',
30166: 'Relay Discovery',
30267: 'App curation sets',
30311: 'Live Event',
30312: 'Interactive Room',
30313: 'Conference Event',
30315: 'User Statuses',
30382: 'User Trusted Assertion',
30383: 'Event Trusted Assertion',
30384: 'Addressable Trusted Assertion',
30388: 'Slide Set',
30402: 'Classified Listing',
30403: 'Draft Classified Listing',
30617: 'Repository announcements',
30618: 'Repository state announcements',
30818: 'Wiki article',
30819: 'Redirects',
31234: 'Draft Event',
31388: 'Link Set',
31890: 'Feed',
31922: 'Date-Based Calendar Event',
31923: 'Time-Based Calendar Event',
31924: 'Calendar',
31925: 'Calendar Event RSVP',
31989: 'Handler recommendation',
31990: 'Handler information',
32267: 'Software Application',
32388: 'User Room Favorites',
33388: 'High Scores',
34235: 'Addressable Video Event',
34236: 'Addressable Short Video Event',
34388: 'Sound Effects',
37516: 'Geocache listing',
38383: 'Peer-to-peer Order events',
39089: 'Starter packs',
39092: 'Media starter packs',
39701: 'Web bookmarks',
// App-local kinds used in this client
10123: 'Skill Adoption',
31123: 'Skill (Public)',
31124: 'Skill (Private)'
};
const KIND_LABEL_RANGES = [
{ min: 1630, max: 1633, label: 'Status' },
{ min: 5000, max: 5999, label: 'Job Request' },
{ min: 6000, max: 6999, label: 'Job Result' },
{ min: 9000, max: 9030, label: 'Group Control Events' },
{ min: 39000, max: 39009, label: 'Group metadata events' }
];
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;
let lastDecryptResult = null;
let allEventsFetchSub = null;
let allEventsFetchInFlight = false;
let allEventsFetchTimeoutId = 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 readableRelayUrls = []; // read/both relays only (excludes write-only) for Refresh Kind queries
let refreshKindDebugInFlight = false;
const autoRefreshedKinds = new Set();
const BRAILLE_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let brailleSpinnerFrameIndex = 0;
let brailleSpinnerTimerId = null;
let eventDetailResizableSplit = null;
let eventBodyResizableColumns = 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 btnGetAllEvents = document.getElementById('btnGetAllEvents');
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 txtEventJson = document.getElementById('txtEventJson');
const btnCopyEventId = document.getElementById('btnCopyEventId');
const btnCopyEventJson = document.getElementById('btnCopyEventJson');
const btnEditRepostEvent = document.getElementById('btnEditRepostEvent');
const btnDeleteEvent = document.getElementById('btnDeleteEvent');
const btnBlockEvent = document.getElementById('btnBlockEvent');
const btnRemoveEventFromCache = document.getElementById('btnRemoveEventFromCache');
const btnDeleteBlockRemoveEvent = document.getElementById('btnDeleteBlockRemoveEvent');
const divDecryptMeta = document.getElementById('divDecryptMeta');
const txtDecryptedContent = document.getElementById('txtDecryptedContent');
function setDetailActionButtonsEnabled(enabled) {
const disabled = !Boolean(enabled);
[btnCopyEventId, btnCopyEventJson, btnEditRepostEvent, btnDeleteEvent, btnBlockEvent, btnRemoveEventFromCache, btnDeleteBlockRemoveEvent]
.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);
if (!Number.isFinite(n)) return 'Unknown Kind';
if (KIND_LABELS[n]) return KIND_LABELS[n];
const range = KIND_LABEL_RANGES.find((entry) => n >= entry.min && n <= entry.max);
if (range) return `${range.label} (${range.min}-${range.max})`;
return '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 autosizeDecryptedContent() {
if (!txtDecryptedContent || eventDetailResizableSplit) return;
const minPx = 90;
const maxPx = Math.max(minPx, Math.floor(window.innerHeight * 0.5));
txtDecryptedContent.style.height = 'auto';
const next = Math.max(minPx, Math.min(txtDecryptedContent.scrollHeight + 2, maxPx));
txtDecryptedContent.style.height = `${next}px`;
txtDecryptedContent.style.overflowY = 'auto';
}
function readSavedEventDetailHeights() {
try {
const raw = localStorage.getItem('event-management:detail-heights:v1');
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 2) return null;
const heights = parsed.map((value, index) => Math.max(index === 0 ? 180 : 90, Math.round(Number(value) || 0)));
return heights.every((value) => Number.isFinite(value) && value > 0) ? heights : null;
} catch {
return null;
}
}
function saveEventDetailHeights(heights) {
try {
localStorage.setItem('event-management:detail-heights:v1', JSON.stringify(heights));
} catch {
// Ignore storage write failures
}
}
function readSavedEventColumnWidths() {
try {
const raw = localStorage.getItem('event-management:column-widths:v1');
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 3) return null;
const widths = parsed.map((value, index) => Math.max([220, 320, 320][index], Math.round(Number(value) || 0)));
return widths.every((value) => Number.isFinite(value) && value > 0) ? widths : null;
} catch {
return null;
}
}
function saveEventColumnWidths(widths) {
try {
localStorage.setItem('event-management:column-widths:v1', JSON.stringify(widths));
} catch {
// Ignore storage write failures
}
}
function initEventBodyResizableColumns() {
if (!divBody) return;
if (eventBodyResizableColumns?.destroy) {
eventBodyResizableColumns.destroy();
}
eventBodyResizableColumns = initResizableColumns({
container: divBody,
columnVarNames: ['--event-col-1', '--event-col-2', '--event-col-3'],
minWidths: [220, 320, 320],
gutterSelector: '.eventBodyGutter[data-gutter-index]',
compactMediaQuery: '(max-width: 1300px)',
gutterSizeVarName: '--event-body-gutter-size',
defaultGutterSize: 10,
initialWidths: readSavedEventColumnWidths(),
onWidthsChange: (widths, { persist } = {}) => {
if (persist) saveEventColumnWidths(widths);
},
});
}
function initEventDetailResizableSplit() {
const splitEl = document.getElementById('eventDetailSplit');
if (!splitEl) return;
if (eventDetailResizableSplit?.destroy) {
eventDetailResizableSplit.destroy();
}
eventDetailResizableSplit = initResizableColumns({
container: splitEl,
columnVarNames: ['--event-detail-row-1', '--event-detail-row-2'],
minWidths: [180, 90],
gutterSelector: '.eventDetailGutter[data-gutter-index]',
compactMediaQuery: '(max-width: 0px)',
gutterSizeVarName: '--event-detail-gutter-size',
defaultGutterSize: 10,
axis: 'y',
initialWidths: readSavedEventDetailHeights(),
onWidthsChange: (heights, { persist } = {}) => {
if (persist) saveEventDetailHeights(heights);
},
});
}
function setDecryptedOutput(text, meta = 'Decrypted content will appear here.', { editable = false } = {}) {
if (divDecryptMeta) divDecryptMeta.textContent = String(meta || 'Decrypted content will appear here.');
if (txtDecryptedContent) {
txtDecryptedContent.value = String(text || '(none)');
txtDecryptedContent.readOnly = !editable;
autosizeDecryptedContent();
}
}
function isLikelyNip04Content(content) {
return typeof content === 'string' && content.includes('?iv=');
}
function isLikelyNip44Content(content) {
const raw = String(content || '').trim();
if (!raw) return false;
if (raw.startsWith('nip44v2:')) return true;
if (!/^[A-Za-z0-9+/=]+$/.test(raw) || raw.length < 24) return false;
try {
const bin = atob(raw);
if (!bin || bin.length < 40) return false;
// NIP-44 v2 payloads start with version byte 0x02.
return bin.charCodeAt(0) === 2;
} catch (_error) {
return false;
}
}
function isLikelyEncryptedEvent(evt) {
if (!evt) return false;
const kind = Number(evt.kind || 0);
const content = String(evt.content || '').trim();
if (!content) return false;
// Known encrypted/event-carrier kinds used in this app.
if (kind === 4 || kind === 44 || kind === 30078 || kind === 31124) return true;
if (isLikelyNip04Content(content)) return true;
if (isLikelyNip44Content(content)) 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 || kind === 31124 || isLikelyNip44Content(ciphertext);
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;
}
})();
lastDecryptResult = {
method: String(result.method || ''),
peerPubkey: String(result.peerPubkey || ''),
plaintextRaw: String(result.plaintext || ''),
plaintextDisplay: String(prettyPlain || '')
};
setDecryptedOutput(
prettyPlain,
`Decrypted via ${result.method} using peer ${shortHex(result.peerPubkey)}`,
{ editable: true }
);
} catch (error) {
if (sequence !== decryptAttemptSequence) return;
if (!currentDetailEvent || eventKeyFor(currentDetailEvent) !== eventKeyFor(detailEvent)) return;
lastDecryptResult = null;
setDecryptedOutput('(decrypt failed)', `Decrypt failed: ${error?.message || String(error)}`, { editable: false });
}
}
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;
// subscribedRelayUrls includes all relays (read, write, both) so that
// write-only relays still appear as publish-target chips and are valid
// targets for the "publish to missing relays" flow.
const urls = [...new Set(scopedRows
.map((r) => String(r?.url || '').trim())
.filter(Boolean))];
subscribedRelayUrls = urls;
// readableRelayUrls excludes write-only relays (NIP-65 type === 'write')
// so the Refresh Kind query loop never tries to READ from a write-only
// relay. Falls back to the full list when type info is unavailable.
const hasTypeinfo = scopedRows.some((r) => r?.type);
readableRelayUrls = hasTypeinfo
? [...new Set(scopedRows
.filter((r) => r?.type !== 'write')
.map((r) => String(r?.url || '').trim())
.filter(Boolean))]
: [...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 isEventMutedSafe(evt) {
try {
return Boolean(isEventMuted?.(evt));
} catch (_error) {
return false;
}
}
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 getCachedKindsAndEventTotals() {
const kinds = getKindsSorted();
const totalEvents = kinds.reduce((sum, k) => sum + getEventsForKind(k).length, 0);
return { kindsCount: kinds.length, totalEvents };
}
function renderKinds() {
const kinds = getKindsSorted();
if (!kinds.length) {
divKindsList.innerHTML = '<div class="hint">No cached events found.</div>';
if (!allEventsFetchInFlight) divKindsStatus.textContent = '0 kinds · 0 events';
return;
}
const totalEvents = kinds.reduce((sum, k) => sum + getEventsForKind(k).length, 0);
if (!allEventsFetchInFlight) 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 muted = isEventMutedSafe(evt);
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' : ''} ${muted ? 'muted' : ''}" 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' : ''} ${muted ? 'mutedBadge' : ''}">${escapeHtml(created)}</span>
</div>
<div class="rowMeta">${escapeHtml(preview)}</div>
${muted ? '<div class="rowMeta mutedMeta">blocked/muted event</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;
lastDecryptResult = null;
divDetailMeta.textContent = 'Select an event from the middle column.';
if (txtEventJson) txtEventJson.value = '{}';
setDecryptedOutput('(none)', 'Decrypted content will appear here.', { editable: false });
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;
lastDecryptResult = null;
if (txtEventJson) txtEventJson.value = JSON.stringify(pretty, null, 2);
setDecryptedOutput('(none)', 'Decrypted content will appear here.', { editable: false });
setDetailActionButtonsEnabled(true);
const sequence = ++decryptAttemptSequence;
if (!isLikelyEncryptedEvent(pretty)) {
setDecryptedOutput('(none)', 'Content does not appear encrypted.', { editable: false });
return;
}
setDecryptedOutput('(decrypting...)', 'Detected encrypted content. Attempting automatic decryption...', { editable: false });
void autoDecryptDetailEvent(pretty, sequence);
}
function buildRepostConfirmationMessage(kind, tags, usedEncryption) {
const k = Number(kind || 0);
const replaceable = isReplaceableKind(k) || isParameterizedReplaceableKind(k);
let msg = usedEncryption
? `Re-encrypt and publish updated event (kind ${k})?`
: `Publish updated event (kind ${k}) with a fresh timestamp?`;
if (!replaceable) {
msg += '\n\nThis kind is not replaceable, so this will create a new event.';
}
if (isParameterizedReplaceableKind(k) && !getDTag(tags)) {
msg += '\n\nWarning: this parameterized replaceable event has no d-tag.';
}
return msg;
}
async function encryptWithDetectedMethod(method, peerPubkey, plaintext) {
const m = String(method || '').toUpperCase();
const peer = String(peerPubkey || '');
const body = String(plaintext || '');
if (!peer) throw new Error('Cannot re-encrypt: peer pubkey missing');
if (m === 'NIP-04') {
if (!window?.nostr?.nip04?.encrypt) throw new Error('NIP-04 encrypt unavailable');
return await window.nostr.nip04.encrypt(peer, body);
}
if (m === 'NIP-44') {
if (!window?.nostr?.nip44?.encrypt) throw new Error('NIP-44 encrypt unavailable');
return await window.nostr.nip44.encrypt(peer, body);
}
throw new Error(`Unsupported encryption method: ${method}`);
}
async function repostEditedCurrentEvent() {
if (!currentDetailEvent?.id) throw new Error('No selected event');
if (!txtEventJson) throw new Error('Event JSON editor not available');
if (!window?.nostr?.signEvent) throw new Error('window.nostr.signEvent unavailable');
let parsed;
try {
parsed = JSON.parse(String(txtEventJson.value || '{}'));
} catch (error) {
throw new Error(`Invalid event JSON: ${error?.message || String(error)}`);
}
const kind = Number(parsed?.kind);
if (!Number.isFinite(kind)) throw new Error('Event JSON must include a numeric kind');
const tags = Array.isArray(parsed?.tags)
? parsed.tags.map((t) => Array.isArray(t) ? t.map((v) => String(v ?? '')) : [String(t ?? '')])
: [];
let finalContent = String(parsed?.content || '');
let usedEncryption = false;
const editedPlaintext = String(txtDecryptedContent?.value || '');
const canUseDecryptedEditor = Boolean(lastDecryptResult && txtDecryptedContent && !txtDecryptedContent.readOnly);
if (canUseDecryptedEditor && editedPlaintext !== String(lastDecryptResult.plaintextDisplay || '')) {
finalContent = await encryptWithDetectedMethod(lastDecryptResult.method, lastDecryptResult.peerPubkey, editedPlaintext);
usedEncryption = true;
parsed.content = finalContent;
txtEventJson.value = JSON.stringify(parsed, null, 2);
}
const confirmMsg = buildRepostConfirmationMessage(kind, tags, usedEncryption);
if (!window.confirm(confirmMsg)) return null;
const draft = {
kind,
created_at: Math.floor(Date.now() / 1000),
tags,
content: String(finalContent || '')
};
const published = await publishEvent(draft);
return { published, draft, usedEncryption };
}
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' });
}
function clearAllEventsFetchTimeout() {
if (allEventsFetchTimeoutId) {
clearTimeout(allEventsFetchTimeoutId);
allEventsFetchTimeoutId = null;
}
}
function closeAllEventsFetchSubscription() {
try {
if (allEventsFetchSub && typeof allEventsFetchSub.close === 'function') allEventsFetchSub.close();
} catch (_error) { }
allEventsFetchSub = null;
}
function finalizeGetAllEventsFetch(statusText) {
clearAllEventsFetchTimeout();
closeAllEventsFetchSubscription();
allEventsFetchInFlight = false;
if (btnGetAllEvents) btnGetAllEvents.disabled = false;
if (statusText) {
divKindsStatus.textContent = statusText;
} else {
const { kindsCount, totalEvents } = getCachedKindsAndEventTotals();
divKindsStatus.textContent = `${kindsCount} kinds · ${totalEvents.toLocaleString()} cached events`;
}
}
async function runOneShotGetAllEventsFetch() {
if (allEventsFetchInFlight) return;
if (!currentPubkey) {
divKindsStatus.textContent = 'Get all events failed: user pubkey unavailable';
return;
}
allEventsFetchInFlight = true;
if (btnGetAllEvents) btnGetAllEvents.disabled = true;
const beforeTotals = getCachedKindsAndEventTotals();
divKindsStatus.textContent = `Getting all events from relays for ${shortHex(currentPubkey)}...`;
try {
closeAllEventsFetchSubscription();
allEventsFetchSub = subscribe(
{ authors: [currentPubkey] },
{ closeOnEose: true, cacheUsage: 'CACHE_FIRST' }
);
allEventsFetchTimeoutId = setTimeout(() => {
const afterTotals = getCachedKindsAndEventTotals();
const added = Math.max(0, afterTotals.totalEvents - beforeTotals.totalEvents);
finalizeGetAllEventsFetch(`Get all events timed out · +${added.toLocaleString()} events (${afterTotals.totalEvents.toLocaleString()} total)`);
}, 30000);
} catch (error) {
finalizeGetAllEventsFetch(`Get all events failed: ${error?.message || String(error)}`);
}
}
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);
// Use readableRelayUrls (excludes write-only relays) so Refresh Kind
// never tries to READ from a write-only relay like sendit.nosflare.com.
const relayUrls = [...readableRelayUrls];
console.log('[event-management] refresh:relayUrls scoped for query (read-only)', relayUrls, {
subscribedRelayUrls: subscribedRelayUrls,
readableRelayUrls: readableRelayUrls
});
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');
document.documentElement.classList.toggle('dark-mode', isDarkMode);
document.body.classList.toggle('dark-mode', isDarkMode);
if (themeToggleHamburger) {
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
});
logoutButton?.addEventListener('click', async () => {
closeAllEventsFetchSubscription();
clearAllEventsFetchTimeout();
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)}`;
}
});
btnGetAllEvents?.addEventListener('click', async () => {
await runOneShotGetAllEventsFetch();
});
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 || !txtEventJson) return;
try {
await navigator.clipboard.writeText(String(txtEventJson.value || '{}'));
divKindsStatus.textContent = 'Copied event JSON to clipboard';
} catch (_error) {
divKindsStatus.textContent = 'Copy event JSON failed';
}
});
btnEditRepostEvent?.addEventListener('click', async () => {
if (!currentDetailEvent?.id) return;
try {
const result = await repostEditedCurrentEvent();
if (!result) return;
const postedKind = Number(result?.draft?.kind || 0);
const postedEventId = String(result?.published?.eventId || result?.published?.id || '');
const postedLabel = shortHex(postedEventId);
divKindsStatus.textContent = result.usedEncryption
? `Re-encrypted + reposted kind ${postedKind}${postedLabel ? ` · ${postedLabel}` : ''}`
: `Reposted kind ${postedKind}${postedLabel ? ` · ${postedLabel}` : ''}`;
if (Number.isFinite(postedKind)) {
autoRefreshedKinds.delete(postedKind);
await refreshKindFromRelays(postedKind);
let nextSelection = null;
if (postedEventId) {
const candidateKey = `${postedKind}:${postedEventId}`;
nextSelection = findEventByKey(candidateKey);
if (nextSelection) selectedEventKey = candidateKey;
}
if (!nextSelection && (isReplaceableKind(postedKind) || isParameterizedReplaceableKind(postedKind))) {
const draftTags = Array.isArray(result?.draft?.tags) ? result.draft.tags : [];
const draftGroupKey = getReplaceableGroupKey({ kind: postedKind, tags: draftTags });
const candidates = getEventsForKind(postedKind)
.filter((evt) => getReplaceableGroupKey(evt) === draftGroupKey)
.sort((a, b) => Number(b.created_at || 0) - Number(a.created_at || 0));
nextSelection = candidates[0] || null;
if (nextSelection) selectedEventKey = eventKeyFor(nextSelection);
}
if (nextSelection) {
renderEvents();
renderDetail(nextSelection);
}
}
} catch (error) {
divKindsStatus.textContent = `Repost failed: ${error?.message || String(error)}`;
}
});
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);
renderEvents();
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)}`;
}
});
btnDeleteBlockRemoveEvent?.addEventListener('click', async () => {
if (!currentDetailEvent?.id || !Number.isFinite(Number(currentDetailEvent?.kind))) return;
const targetEventId = String(currentDetailEvent.id || '');
const targetKind = Number(currentDetailEvent.kind || 0);
const targetSnapshot = {
...currentDetailEvent,
id: targetEventId,
kind: targetKind
};
const ok = window.confirm(
`Delete, block, and remove ${shortHex(targetEventId)} (kind ${targetKind}) from cache?\n\nThis will:\n1) Publish deletion request\n2) Add event to block list\n3) Remove local cache rows`
);
if (!ok) return;
const errors = [];
let removed = 0;
try {
const deletionEvent = {
kind: 5,
created_at: Math.floor(Date.now() / 1000),
tags: [
['e', targetEventId],
['k', String(targetKind)]
],
content: ''
};
await publishEvent(deletionEvent);
} catch (error) {
errors.push(`delete: ${error?.message || String(error)}`);
}
try {
await addMute('e', targetEventId, false);
} catch (error) {
errors.push(`block: ${error?.message || String(error)}`);
}
try {
removed = await removeEventFromCache(targetSnapshot);
} catch (error) {
errors.push(`remove-cache: ${error?.message || String(error)}`);
}
removeEventFromUiState(targetKind, targetEventId);
if (!errors.length) {
divKindsStatus.textContent = `Deleted + blocked + removed ${removed} cached row(s) for ${shortHex(targetEventId)}`;
} else {
divKindsStatus.textContent = `Combined action completed with issues for ${shortHex(targetEventId)} (${errors.join(' | ')})`;
}
renderEvents();
});
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('ndkEose', (event) => {
if (!allEventsFetchInFlight || !allEventsFetchSub?.subId) return;
const eoseSubId = String(event?.detail?.subId || '');
if (!eoseSubId || eoseSubId !== String(allEventsFetchSub.subId)) return;
const { kindsCount, totalEvents } = getCachedKindsAndEventTotals();
finalizeGetAllEventsFetch(`Get all events complete · ${kindsCount} kinds · ${totalEvents.toLocaleString()} cached events`);
});
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();
initEventBodyResizableColumns();
initEventDetailResizableSplit();
await initNDKPage();
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);
await ensureMuteListLoaded().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);
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();
</script>
</body>
</html>