2504 lines
87 KiB
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>
|