Files
client/www/old/event.html
T

1524 lines
54 KiB
HTML

<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>EVENT</title>
<link rel="stylesheet" href="./css/client.css" />
<!-- Initialize theme BEFORE any components load -->
<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" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="../js/vendor/svg.min.js"></script>
<style media="screen">
#divBody {
display: flex;
flex-direction: column;
padding: 20px;
font-family: var(--font-family);
overflow-y: auto;
gap: 15px;
}
#divKindButtons {
display: flex;
flex-direction: row;
gap: 10px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.btnKind {
flex: 1;
min-width: 100px;
padding: 10px 20px;
border: var(--border-width) solid var(--primary-color);
background-color: var(--secondary-color);
color: var(--primary-color);
font-family: var(--font-family);
font-size: 14px;
cursor: pointer;
border-radius: var(--border-radius);
transition: background-color 0.2s;
}
.btnKindActive {
background-color: var(--primary-color) !important;
color: var(--secondary-color) !important;
border-color: var(--primary-color) !important;
}
#divEventEditor {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
}
#txtEventJson {
flex: 1;
min-height: 300px;
padding: 15px;
border: var(--border-width) solid var(--muted-color);
border-radius: var(--border-radius);
font-family: 'Courier New', monospace;
font-size: 13px;
background-color: var(--secondary-color);
color: var(--primary-color);
resize: vertical;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
#txtEventJson.error {
border-color: red;
background-color: #ffe6e6;
}
#txtEventJson.valid {
border-color: green;
background-color: #e6ffe6;
}
#divValidationStatus {
padding: 10px;
border-radius: var(--border-radius);
font-size: 14px;
min-height: 40px;
}
#divValidationStatus.error {
background-color: #ffe6e6;
color: #cc0000;
border: 1px solid #cc0000;
}
#divValidationStatus.valid {
background-color: #e6ffe6;
color: #006600;
border: 1px solid #006600;
}
#divValidationStatus.info {
background-color: #e6f3ff;
color: #0066cc;
border: 1px solid #0066cc;
}
#divActionButtons {
display: flex;
flex-direction: row;
gap: 10px;
margin-top: 10px;
}
.btnAction {
flex: 1;
padding: 15px 20px;
border: var(--border-width) solid var(--primary-color);
background-color: var(--secondary-color);
color: var(--primary-color);
font-family: var(--font-family);
font-size: 16px;
cursor: pointer;
border-radius: var(--border-radius);
transition: all 0.2s;
}
.btnAction:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#divEventInfo {
display: flex;
flex-direction: row;
gap: 20px;
font-size: 13px;
color: var(--muted-color);
flex-wrap: wrap;
}
.infoItem {
display: flex;
gap: 5px;
}
.infoLabel {
font-weight: bold;
}
</style>
</head>
<body>
<!-- Hamburger Button -->
<div id="divSvgHam" class="divHeaderButtons"></div>
<!-- Header -->
<div id="divHeader">
<div id="divHeaderFlexLeft"></div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText">EVENT</div>
</div>
<div id="divHeaderFlexRight"></div>
</div>
<!-- Body -->
<div id="divBody">
<!-- Kind Selection Buttons -->
<div id="divKindButtons">
<button id="btnKind0" class="btn btnKind" data-kind="0">Kind 0 (Profile)</button>
<button id="btnKind3" class="btn btnKind" data-kind="3">Kind 3 (Contacts)</button>
<button id="btnKind10002" class="btn btnKind" data-kind="10002">Kind 10002 (Relays)</button>
<button id="btnCashuWallet" class="btn btnKind">Cashu wallet</button>
<button id="btnAddressable" class="btn btnKind">Addressable Kinds</button>
</div>
<!-- Addressable Events Selector -->
<div id="divAddressableSelector" style="display: none; margin: 10px 0;">
<select id="selAddressable" style="width: 100%; padding: 10px; border: var(--border-width) solid var(--muted-color); border-radius: var(--border-radius); font-family: var(--font-family); font-size: 14px; background-color: var(--secondary-color); color: var(--primary-color); cursor: pointer;">
<option value="">-- Select an addressable event --</option>
</select>
</div>
<!-- Cashu Events Selector -->
<div id="divCashuSelector" style="display: none; margin: 10px 0;">
<select id="selCashu" style="width: 100%; padding: 10px; border: var(--border-width) solid var(--muted-color); border-radius: var(--border-radius); font-family: var(--font-family); font-size: 14px; background-color: var(--secondary-color); color: var(--primary-color); cursor: pointer;">
<option value="">-- Select a Cashu event --</option>
</select>
</div>
<!-- Event List Table -->
<div id="divEventList" style="display: none; margin: 15px 0; max-height: 200px; overflow-y: auto;">
<table id="tblEvents" style="width: 100%; border-collapse: collapse; font-size: 12px;">
<thead>
<tr style="background: var(--color-bg-secondary); position: sticky; top: 0;">
<th style="padding: 8px; text-align: left; border-bottom: 1px solid var(--color-border);">Relay</th>
<th style="padding: 8px; text-align: left; border-bottom: 1px solid var(--color-border);">Latest Event</th>
<th style="padding: 8px; text-align: left; border-bottom: 1px solid var(--color-border);">Date</th>
<th style="padding: 8px; text-align: center; border-bottom: 1px solid var(--color-border);">Num Events</th>
<th style="padding: 8px; text-align: center; border-bottom: 1px solid var(--color-border);">Status</th>
</tr>
</thead>
<tbody id="tbodyEvents"></tbody>
</table>
</div>
<!-- Event Info -->
<div id="divEventInfo">
<div class="infoItem">
<span class="infoLabel">Kind:</span>
<span id="spanKind">-</span>
</div>
<div class="infoItem">
<span class="infoLabel">Created:</span>
<span id="spanCreated">-</span>
</div>
<div class="infoItem">
<span class="infoLabel">ID:</span>
<span id="spanId">-</span>
</div>
</div>
<!-- Event Editor -->
<div id="divEventEditor">
<textarea id="txtEventJson" placeholder="Select an event kind above to load your existing event, or paste event JSON here..."></textarea>
</div>
<!-- Validation Status -->
<div id="divValidationStatus" class="info">
Select an event kind to load your existing event
</div>
<!-- Action Buttons -->
<div id="divActionButtons">
<button id="btnRepost" class="btn btnAction" disabled>Repost (as-is)</button>
<button id="btnResign" class="btn btnAction" disabled>Resign & Post</button>
</div>
</div>
<!-- Footer -->
<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>
<!-- Sidenav -->
<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">loading...</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-lite.js"></script>
<script type="module">
import { initNDKPage, getPubkey, injectHeaderAvatar, subscribe, publishEvent, disconnect, getRelayData, fetchEventsFromAllRelays, ndkFetchEvents, queryCache } 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 { strTechDate } from './js/utilities.mjs';
// Fetch version from JSON
const versionResponse = await fetch('./js/version.json');
const versionData = await versionResponse.json();
const VERSION = versionData.VERSION;
console.log(`[event.html ${VERSION}] Loading...`);
/* ================================================================
CONFIG
================================================================ */
// Addressable event kinds to fetch
const ADDRESSABLE_KINDS = [30023, 30024, 30078, 32220, 32221, 32222];
const ADDRESSABLE_KIND_LABELS = {
30023: 'Long-form Article',
30024: 'Draft Article',
30078: 'App-specific Data',
32220: 'Strudel Instrument',
32221: 'Strudel Song',
32222: 'Strudel Sound'
};
// NIP-60 Cashu wallet event kinds
const CASHU_KINDS = [17375, 7375, 7376, 7374];
const CASHU_KIND_LABELS = {
17375: 'Wallet',
7375: 'Token',
7376: 'Spending History',
7374: 'Quote'
};
/* ================================================================
GLOBAL VARIABLES
================================================================ */
let updateIntervalId = null;
let currentPubkey = null;
let hamburgerInstance = null;
let isNavOpen = false;
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
// Event state
let currentEvent = null;
let currentKind = null;
let originalEvent = null;
let originalEventJson = null; // Store original JSON to detect modifications
let eventsByRelay = {}; // Store fetched events grouped by relay URL
// Addressable events state
let addressableEventGroups = new Map(); // Map<'kind:d-value', { latestEvent, eventsByRelay }>
let currentAddressableKey = null;
// Cashu (NIP-60) events state
let cashuEventGroups = new Map(); // Map<'kind:id', { event, eventsByRelay }>
let currentCashuKey = null;
/* ================================================================
DOM VARIABLES
================================================================ */
const divBody = document.getElementById("divBody");
const divSideNav = document.getElementById("divSideNav");
const divSideNavBody = document.getElementById("divSideNavBody");
const divFooterCenter = document.getElementById("divFooterCenter");
const divFooterRight = document.getElementById("divFooterRight");
const txtEventJson = document.getElementById("txtEventJson");
const divValidationStatus = document.getElementById("divValidationStatus");
const btnRepost = document.getElementById("btnRepost");
const btnResign = document.getElementById("btnResign");
const spanKind = document.getElementById("spanKind");
const spanCreated = document.getElementById("spanCreated");
const spanId = document.getElementById("spanId");
const divAddressableSelector = document.getElementById("divAddressableSelector");
const selAddressable = document.getElementById("selAddressable");
const divCashuSelector = document.getElementById("divCashuSelector");
const selCashu = document.getElementById("selCashu");
/* ================================================================
HAMBURGER MENU
================================================================ */
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');
const initialShape = isDarkMode ? 'moon' : 'circle';
themeToggleHamburger.animateTo(initialShape);
}
}
function closeNav() {
divSideNav.style.width = "0vw";
divSideNav.style.zIndex = -1;
isNavOpen = false;
if (hamburgerInstance) {
hamburgerInstance.animateTo('burger');
}
}
function toggleNav() {
if (isNavOpen) {
closeNav();
} else {
openNav();
}
}
/* ================================================================
EVENT VALIDATION
================================================================ */
function validateEvent(jsonStr) {
try {
// Check if valid JSON
const event = JSON.parse(jsonStr);
// Check required Nostr event fields
const requiredFields = ['kind', 'content', 'created_at', 'tags', 'pubkey'];
const missingFields = requiredFields.filter(field => !(field in event));
if (missingFields.length > 0) {
return {
valid: false,
error: `Missing required fields: ${missingFields.join(', ')}`
};
}
// Check field types
if (typeof event.kind !== 'number') {
return { valid: false, error: 'kind must be a number' };
}
if (typeof event.content !== 'string') {
return { valid: false, error: 'content must be a string' };
}
if (typeof event.created_at !== 'number') {
return { valid: false, error: 'created_at must be a number' };
}
if (!Array.isArray(event.tags)) {
return { valid: false, error: 'tags must be an array' };
}
if (typeof event.pubkey !== 'string') {
return { valid: false, error: 'pubkey must be a string' };
}
// Validate tags structure
for (let i = 0; i < event.tags.length; i++) {
if (!Array.isArray(event.tags[i])) {
return { valid: false, error: `tags[${i}] must be an array` };
}
}
return { valid: true, event };
} catch (error) {
return { valid: false, error: `Invalid JSON: ${error.message}` };
}
}
function updateValidationStatus() {
const jsonStr = txtEventJson.value.trim();
if (!jsonStr) {
divValidationStatus.className = 'info';
divValidationStatus.textContent = 'Enter or paste event JSON';
txtEventJson.className = '';
btnRepost.disabled = true;
btnResign.disabled = true;
return;
}
const result = validateEvent(jsonStr);
if (result.valid) {
divValidationStatus.className = 'valid';
divValidationStatus.textContent = `✅ Valid Nostr event (Kind ${result.event.kind})`;
txtEventJson.className = 'valid';
currentEvent = result.event;
updateEventInfo(result.event);
// Check if event has been modified from original
const isModified = originalEventJson && jsonStr !== originalEventJson;
// Repost (as-is) only works if:
// 1. Event hasn't been modified from original
// 2. Event has a valid signature (sig field exists)
const canRepost = !isModified && result.event.sig;
btnRepost.disabled = !canRepost;
// Resign & Post always works for valid events
btnResign.disabled = false;
// Show warning if modified
if (isModified) {
divValidationStatus.textContent += ' (Modified - use Resign & Post)';
} else if (!result.event.sig) {
divValidationStatus.textContent += ' (No signature - use Resign & Post)';
}
} else {
divValidationStatus.className = 'error';
divValidationStatus.textContent = `❌ ${result.error}`;
txtEventJson.className = 'error';
btnRepost.disabled = true;
btnResign.disabled = true;
currentEvent = null;
}
}
function updateEventInfo(event) {
spanKind.textContent = event.kind;
spanCreated.textContent = new Date(event.created_at * 1000).toLocaleString();
spanId.textContent = event.id ? event.id.substring(0, 16) + '...' : 'Not signed';
}
function formatEventJson(event) {
return JSON.stringify(event, null, 2);
}
/* ================================================================
INDEXEDDB ACCESS
================================================================ */
const DB_NAME = 'ndk-shared';
// Don't specify version - let IndexedDB open whatever version exists
let db = null;
async function openDatabase() {
return new Promise((resolve, reject) => {
// Open without version to use existing database version
const request = indexedDB.open(DB_NAME);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const db = request.result;
console.log('[event.html] Database opened, version:', db.version, 'stores:', Array.from(db.objectStoreNames));
resolve(db);
};
});
}
function parseNostrEvent(dbEvent) {
// IndexedDB stores events with 'event' field as serialized array
// Format: [0, pubkey, created_at, kind, tags, content, sig]
try {
if (dbEvent.event && typeof dbEvent.event === 'string') {
const eventArray = JSON.parse(dbEvent.event);
return {
id: dbEvent.id,
pubkey: eventArray[1],
created_at: eventArray[2],
kind: eventArray[3],
tags: eventArray[4],
content: eventArray[5],
sig: eventArray[6]
};
}
// Fallback: use dbEvent directly but normalize field names
return {
id: dbEvent.id,
pubkey: dbEvent.pubkey,
created_at: dbEvent.createdAt || dbEvent.created_at,
kind: dbEvent.kind,
tags: dbEvent.tags || [],
content: dbEvent.content || '',
sig: dbEvent.sig
};
} catch (error) {
console.error('[event.html] Failed to parse event:', error, dbEvent);
return null;
}
}
async function getEventsFromDB(kind, pubkey) {
try {
if (!db) {
db = await openDatabase();
}
return new Promise((resolve, reject) => {
const transaction = db.transaction(['events'], 'readonly');
const store = transaction.objectStore('events');
// Use the kind index to get all events of this kind
const index = store.index('kind');
const request = index.getAll(kind);
request.onerror = () => {
console.error('[event.html] IndexedDB query error:', request.error);
reject(request.error);
};
request.onsuccess = () => {
const results = request.result;
console.log(`[event.html] Found ${results.length} events of kind ${kind}`, results[0]);
// Parse and filter by pubkey, sort by created_at (newest first)
const userEvents = results
.map(e => parseNostrEvent(e))
.filter(e => e && e.pubkey === pubkey)
.sort((a, b) => b.created_at - a.created_at);
console.log(`[event.html] User has ${userEvents.length} events of kind ${kind}`);
resolve(userEvents);
};
});
} catch (error) {
console.error('[event.html] Failed to query events from IndexedDB:', error);
return [];
}
}
async function getEventRelays(eventId) {
try {
if (!db) {
db = await openDatabase();
}
// Use eventRelays store which tracks [eventId+relayUrl] pairs
const storeName = 'eventRelays';
return new Promise((resolve, reject) => {
const transaction = db.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
// Use the eventId index to get all relay entries for this event
const index = store.index('eventId');
const request = index.getAll(eventId);
request.onerror = () => {
console.error('[event.html] IndexedDB query error:', request.error);
reject(request.error);
};
request.onsuccess = () => {
const results = request.result;
// Extract relay URLs from eventRelays entries
const relays = results
.map(r => r.relayUrl)
.filter(r => r); // Remove null/undefined
resolve(relays);
};
});
} catch (error) {
console.error('[event.html] Failed to query IndexedDB:', error);
return [];
}
}
/* ================================================================
EVENT LIST TABLE
================================================================ */
const divEventList = document.getElementById('divEventList');
const tbodyEvents = document.getElementById('tbodyEvents');
async function populateEventListFromFetch(kind) {
console.log(`[event.html] Populating event list from fetched data...`);
// Find the most recent event across all relays
let mostRecentEventId = null;
let mostRecentTimestamp = 0;
for (const [relayUrl, events] of Object.entries(eventsByRelay)) {
for (const event of events) {
if (event.created_at > mostRecentTimestamp) {
mostRecentTimestamp = event.created_at;
mostRecentEventId = event.id;
}
}
}
if (Object.keys(eventsByRelay).length === 0) {
divEventList.style.display = 'none';
return;
}
// Clear existing rows
tbodyEvents.innerHTML = '';
// Create a row for each relay
for (const [relayUrl, events] of Object.entries(eventsByRelay)) {
const row = document.createElement('tr');
row.style.cursor = 'pointer';
row.style.borderBottom = '1px solid var(--color-border)';
// Highlight on hover
row.addEventListener('mouseenter', () => {
row.style.background = 'var(--color-bg-secondary)';
});
row.addEventListener('mouseleave', () => {
row.style.background = '';
});
// Find the most recent event for this relay
let latestEvent = null;
if (events.length > 0) {
latestEvent = events.reduce((latest, event) =>
event.created_at > latest.created_at ? event : latest
);
}
// Check if this relay is out of date
const isOutOfDate = latestEvent && latestEvent.id !== mostRecentEventId;
const statusIcon = isOutOfDate ? '⚠️' : '✓';
const statusTitle = isOutOfDate ? 'Out of date - missing latest event' : 'Up to date';
// Format relay domain
const relayDomain = relayUrl.replace('wss://', '').replace('ws://', '').replace(/\/$/, '');
// Load the latest event on click
if (latestEvent) {
row.addEventListener('click', () => {
loadEventFromData(latestEvent, [relayUrl]);
});
}
row.innerHTML = `
<td style="padding: 8px; font-size: 11px;">${relayDomain}</td>
<td style="padding: 8px; font-family: monospace; font-size: 11px;">${latestEvent ? latestEvent.id.substring(0, 4) : 'N/A'}</td>
<td style="padding: 8px; font-size: 11px;">${latestEvent ? strTechDate(new Date(latestEvent.created_at * 1000)) : 'N/A'}</td>
<td style="padding: 8px; text-align: center;">${events.length}</td>
<td style="padding: 8px; text-align: center;" title="${statusTitle}">${statusIcon}</td>
`;
tbodyEvents.appendChild(row);
}
divEventList.style.display = 'block';
console.log(`[event.html] Event list populated with ${Object.keys(eventsByRelay).length} relay(s)`);
}
function loadEventFromData(event, relays) {
console.log('[event.html] Loading event from data:', event);
console.log('[event.html] Event on relays:', relays);
originalEvent = event;
const formattedJson = formatEventJson(event);
txtEventJson.value = formattedJson;
originalEventJson = formattedJson;
updateValidationStatus();
}
/* ================================================================
EVENT LOADING
================================================================ */
async function loadEvent(kind) {
console.log(`[event.html] Fetching kind ${kind} events from all relays...`);
// Update button states
document.querySelectorAll('.btnKind').forEach(btn => {
btn.classList.remove('btnKindActive');
});
const kindButton = document.getElementById(`btnKind${kind}`);
if (kindButton) {
kindButton.classList.add('btnKindActive');
}
currentKind = kind;
currentAddressableKey = null;
currentCashuKey = null;
divAddressableSelector.style.display = 'none';
divCashuSelector.style.display = 'none';
// Clear UI immediately
txtEventJson.value = '';
tbodyEvents.innerHTML = '';
divEventList.style.display = 'none';
// Show loading status
divValidationStatus.className = 'info';
divValidationStatus.textContent = `Fetching kind ${kind} events from relays...`;
try {
// Fetch events from all relays with streaming progress
eventsByRelay = {};
// Use the original fetchEventsFromAllRelays for per-relay attribution in the inspector
// We'll need to restore the original implementation in init-ndk.mjs or use a different name
// For now, we'll use ndkFetchEvents to get the latest event quickly, then background fetch per-relay
const initialFilter = {
kinds: [kind],
authors: [currentPubkey]
};
const cachedInitialEvents = await queryCache(initialFilter).catch(() => []);
if (Array.isArray(cachedInitialEvents) && cachedInitialEvents.length > 0) {
cachedInitialEvents.sort((a, b) => b.created_at - a.created_at);
loadEventFromData(cachedInitialEvents[0], []);
}
void ndkFetchEvents(initialFilter).then((initialEvents) => {
if (Array.isArray(initialEvents) && initialEvents.length > 0) {
initialEvents.sort((a, b) => b.created_at - a.created_at);
loadEventFromData(initialEvents[0], []);
}
}).catch(() => {});
// Background fetch per-relay for the table
const relays = await getRelayData();
const readableRelays = relays.filter(r => r.type !== 'write');
let completed = 0;
const total = readableRelays.length;
for (const relay of readableRelays) {
try {
// Use the raw fetchEvents from init-ndk.mjs
const { fetchEvents: rawFetchEvents } = await import('./js/init-ndk.mjs');
const events = await rawFetchEvents({ kinds: [kind], authors: [currentPubkey] }, relay.url);
eventsByRelay[relay.url] = events || [];
completed++;
divValidationStatus.textContent = `Received ${completed}/${total} relays...`;
populateEventListFromFetch(kind);
} catch (e) {
completed++;
}
}
console.log('[event.html] Fetched events by relay:', eventsByRelay);
// Final population
populateEventListFromFetch(kind);
// Get all unique events and load the most recent
const allEvents = [];
for (const [relayUrl, events] of Object.entries(eventsByRelay)) {
allEvents.push(...events);
}
if (allEvents.length > 0) {
// Sort by created_at and get most recent
allEvents.sort((a, b) => b.created_at - a.created_at);
const mostRecent = allEvents[0];
// Find which relays have this event
const relaysWithEvent = [];
for (const [relayUrl, events] of Object.entries(eventsByRelay)) {
if (events.some(e => e.id === mostRecent.id)) {
relaysWithEvent.push(relayUrl);
}
}
loadEventFromData(mostRecent, relaysWithEvent);
} else {
divValidationStatus.className = 'info';
divValidationStatus.textContent = `No kind ${kind} events found on any relay`;
divEventList.style.display = 'none';
}
} catch (error) {
console.error('[event.html] Failed to fetch events:', error);
divValidationStatus.className = 'error';
divValidationStatus.textContent = `❌ Failed to fetch events: ${error.message}`;
divEventList.style.display = 'none';
}
}
/* ================================================================
ADDRESSABLE EVENTS
================================================================ */
// Helper to get d-tag value from an event
function getDTagValue(event) {
const dTag = event.tags.find(t => t[0] === 'd');
return dTag ? dTag[1] : '';
}
// Helper to create a group key from kind and d-tag
function getAddressableKey(kind, dValue) {
return `${kind}:${dValue}`;
}
async function loadAddressableEvents() {
console.log('[event.html] Loading addressable events...');
// Update button states
document.querySelectorAll('.btnKind').forEach(btn => {
btn.classList.remove('btnKindActive');
});
document.getElementById('btnAddressable').classList.add('btnKindActive');
// Clear UI
txtEventJson.value = '';
tbodyEvents.innerHTML = '';
divEventList.style.display = 'none';
addressableEventGroups.clear();
currentAddressableKey = null;
currentCashuKey = null;
// Hide dropdown initially
divCashuSelector.style.display = 'none';
divAddressableSelector.style.display = 'none';
selAddressable.innerHTML = '<option value="">-- Select an addressable event --</option>';
// Show loading status
divValidationStatus.className = 'info';
divValidationStatus.textContent = `Fetching addressable events (kinds ${ADDRESSABLE_KINDS.join(', ')}) from relays...`;
try {
// Fetch events from all relays with streaming progress
eventsByRelay = {};
const initialAddressableFilter = {
kinds: ADDRESSABLE_KINDS,
authors: [currentPubkey]
};
const initialAddressableEvents = await queryCache(initialAddressableFilter).catch(() => []);
if (Array.isArray(initialAddressableEvents) && initialAddressableEvents.length > 0) {
eventsByRelay.cache = initialAddressableEvents;
groupAddressableEvents();
populateAddressableDropdown();
if (addressableEventGroups.size > 0) {
divValidationStatus.className = 'valid';
divValidationStatus.textContent = `✅ Found ${addressableEventGroups.size} unique addressable event(s) (cache)`;
divAddressableSelector.style.display = 'block';
}
}
void ndkFetchEvents(initialAddressableFilter).catch(() => {});
// Background fetch per-relay for the table
const relays = await getRelayData();
const readableRelays = relays.filter(r => r.type !== 'write');
let completed = 0;
const total = readableRelays.length;
for (const relay of readableRelays) {
try {
const { fetchEvents: rawFetchEvents } = await import('./js/init-ndk.mjs');
const events = await rawFetchEvents({ kinds: ADDRESSABLE_KINDS, authors: [currentPubkey] }, relay.url);
eventsByRelay[relay.url] = events || [];
completed++;
divValidationStatus.textContent = `Received ${completed}/${total} relays...`;
} catch (e) {
completed++;
}
}
console.log('[event.html] Fetched addressable events by relay:', eventsByRelay);
// Group events by kind:d-tag pair
groupAddressableEvents();
// Populate dropdown
populateAddressableDropdown();
if (addressableEventGroups.size > 0) {
divValidationStatus.className = 'valid';
divValidationStatus.textContent = `✅ Found ${addressableEventGroups.size} unique addressable event(s)`;
divAddressableSelector.style.display = 'block';
} else {
divValidationStatus.className = 'info';
divValidationStatus.textContent = `No addressable events found on any relay`;
divEventList.style.display = 'none';
}
} catch (error) {
console.error('[event.html] Failed to fetch addressable events:', error);
divValidationStatus.className = 'error';
divValidationStatus.textContent = `❌ Failed to fetch events: ${error.message}`;
divEventList.style.display = 'none';
}
}
function groupAddressableEvents() {
// Group events by kind:d-tag pair
// Each group will have: latestEvent, eventsByRelay
for (const [relayUrl, events] of Object.entries(eventsByRelay)) {
for (const event of events) {
const dValue = getDTagValue(event);
const key = getAddressableKey(event.kind, dValue);
if (!addressableEventGroups.has(key)) {
addressableEventGroups.set(key, {
kind: event.kind,
dValue: dValue,
latestEvent: event,
eventsByRelay: {}
});
}
const group = addressableEventGroups.get(key);
// Track events per relay for this group
if (!group.eventsByRelay[relayUrl]) {
group.eventsByRelay[relayUrl] = [];
}
group.eventsByRelay[relayUrl].push(event);
// Update latest event if this one is newer
if (event.created_at > group.latestEvent.created_at) {
group.latestEvent = event;
}
}
}
console.log('[event.html] Grouped addressable events:', addressableEventGroups);
}
function populateAddressableDropdown() {
selAddressable.innerHTML = '<option value="">-- Select an addressable event --</option>';
// Sort groups by kind, then by d-value
const sortedGroups = Array.from(addressableEventGroups.entries()).sort((a, b) => {
if (a[1].kind !== b[1].kind) {
return a[1].kind - b[1].kind;
}
return a[1].dValue.localeCompare(b[1].dValue);
});
for (const [key, group] of sortedGroups) {
const label = ADDRESSABLE_KIND_LABELS[group.kind] || `Kind ${group.kind}`;
const displayDValue = group.dValue.length > 30 ? group.dValue.substring(0, 27) + '...' : group.dValue;
const optionText = `Kind ${group.kind} - ${label}: ${displayDValue}`;
const option = document.createElement('option');
option.value = key;
option.textContent = optionText;
selAddressable.appendChild(option);
}
console.log('[event.html] Populated dropdown with', addressableEventGroups.size, 'options');
}
function onAddressableSelected() {
const selectedKey = selAddressable.value;
if (!selectedKey) {
// Clear display if no selection
txtEventJson.value = '';
divEventList.style.display = 'none';
currentAddressableKey = null;
currentEvent = null;
updateValidationStatus();
return;
}
currentAddressableKey = selectedKey;
const group = addressableEventGroups.get(selectedKey);
if (!group) {
console.error('[event.html] Selected group not found:', selectedKey);
return;
}
console.log('[event.html] Selected addressable event:', group);
// Set current kind for reference
currentKind = group.kind;
// Use the group's eventsByRelay for the relay status table
// Filter eventsByRelay to only show events matching this kind:d-tag
const filteredEventsByRelay = {};
for (const [relayUrl, relayEvents] of Object.entries(group.eventsByRelay)) {
filteredEventsByRelay[relayUrl] = relayEvents;
}
// Temporarily replace global eventsByRelay for the table display
const originalEventsByRelay = eventsByRelay;
eventsByRelay = filteredEventsByRelay;
// Populate the relay table showing which relays have this event
populateEventListFromFetch(group.kind);
// Restore global eventsByRelay
eventsByRelay = originalEventsByRelay;
// Load the latest event into the editor
// Find all relays that have the latest event
const relaysWithLatest = [];
for (const [relayUrl, relayEvents] of Object.entries(group.eventsByRelay)) {
if (relayEvents.some(e => e.id === group.latestEvent.id)) {
relaysWithLatest.push(relayUrl);
}
}
loadEventFromData(group.latestEvent, relaysWithLatest);
// Update validation status
const label = ADDRESSABLE_KIND_LABELS[group.kind] || `Kind ${group.kind}`;
divValidationStatus.className = 'valid';
divValidationStatus.textContent = `✅ ${label}: "${group.dValue}" - Latest event loaded`;
}
/* ================================================================
CASHU (NIP-60) EVENTS
================================================================ */
function getCashuEventKey(event) {
return `${event.kind}:${event.id}`;
}
function groupCashuEvents() {
cashuEventGroups.clear();
for (const [relayUrl, events] of Object.entries(eventsByRelay)) {
for (const event of events) {
const key = getCashuEventKey(event);
if (!cashuEventGroups.has(key)) {
cashuEventGroups.set(key, {
kind: event.kind,
id: event.id,
created_at: event.created_at,
event,
eventsByRelay: {}
});
}
const group = cashuEventGroups.get(key);
group.eventsByRelay[relayUrl] = group.eventsByRelay[relayUrl] || [];
group.eventsByRelay[relayUrl].push(event);
if (event.created_at > group.created_at) {
group.created_at = event.created_at;
group.event = event;
}
}
}
console.log('[event.html] Grouped Cashu events:', cashuEventGroups);
}
function populateCashuDropdown() {
selCashu.innerHTML = '<option value="">-- Select a Cashu event --</option>';
const sortedGroups = Array.from(cashuEventGroups.entries()).sort((a, b) => {
if (a[1].kind !== b[1].kind) {
return a[1].kind - b[1].kind;
}
return b[1].created_at - a[1].created_at;
});
for (const [key, group] of sortedGroups) {
const label = CASHU_KIND_LABELS[group.kind] || `Kind ${group.kind}`;
const shortId = group.id?.substring(0, 12) || 'unknown';
const dateText = strTechDate(new Date(group.created_at * 1000));
const optionText = `Kind ${group.kind} - ${label} - ${shortId} - ${dateText}`;
const option = document.createElement('option');
option.value = key;
option.textContent = optionText;
selCashu.appendChild(option);
}
console.log('[event.html] Populated Cashu dropdown with', cashuEventGroups.size, 'options');
}
function onCashuSelected() {
const selectedKey = selCashu.value;
if (!selectedKey) {
txtEventJson.value = '';
divEventList.style.display = 'none';
currentCashuKey = null;
currentEvent = null;
updateValidationStatus();
return;
}
const group = cashuEventGroups.get(selectedKey);
if (!group) {
console.error('[event.html] Selected Cashu group not found:', selectedKey);
return;
}
currentCashuKey = selectedKey;
currentAddressableKey = null;
currentKind = group.kind;
const filteredEventsByRelay = {};
for (const [relayUrl, relayEvents] of Object.entries(group.eventsByRelay)) {
filteredEventsByRelay[relayUrl] = relayEvents;
}
const originalEventsByRelay = eventsByRelay;
eventsByRelay = filteredEventsByRelay;
populateEventListFromFetch(group.kind);
eventsByRelay = originalEventsByRelay;
const relaysWithEvent = Object.keys(group.eventsByRelay);
loadEventFromData(group.event, relaysWithEvent);
const label = CASHU_KIND_LABELS[group.kind] || `Kind ${group.kind}`;
divValidationStatus.className = 'valid';
divValidationStatus.textContent = `✅ Cashu ${label} event loaded (${group.id.substring(0, 16)}...)`;
}
async function loadCashuWalletEvents() {
console.log('[event.html] Loading Cashu wallet events...');
document.querySelectorAll('.btnKind').forEach(btn => {
btn.classList.remove('btnKindActive');
});
document.getElementById('btnCashuWallet').classList.add('btnKindActive');
txtEventJson.value = '';
tbodyEvents.innerHTML = '';
divEventList.style.display = 'none';
currentAddressableKey = null;
currentCashuKey = null;
cashuEventGroups.clear();
divAddressableSelector.style.display = 'none';
divCashuSelector.style.display = 'none';
selCashu.innerHTML = '<option value="">-- Select a Cashu event --</option>';
divValidationStatus.className = 'info';
divValidationStatus.textContent = `Fetching Cashu wallet events (kinds ${CASHU_KINDS.join(', ')}) from relays...`;
try {
eventsByRelay = {};
const initialCashuFilter = {
kinds: CASHU_KINDS,
authors: [currentPubkey]
};
const initialCashuEvents = await queryCache(initialCashuFilter).catch(() => []);
if (Array.isArray(initialCashuEvents) && initialCashuEvents.length > 0) {
eventsByRelay.cache = initialCashuEvents;
groupCashuEvents();
populateCashuDropdown();
if (cashuEventGroups.size > 0) {
divValidationStatus.className = 'valid';
divValidationStatus.textContent = `✅ Found ${cashuEventGroups.size} Cashu event(s) (cache)`;
divCashuSelector.style.display = 'block';
}
}
void ndkFetchEvents(initialCashuFilter).catch(() => {});
// Background fetch per-relay for the table
const relays = await getRelayData();
const readableRelays = relays.filter(r => r.type !== 'write');
let completed = 0;
const total = readableRelays.length;
for (const relay of readableRelays) {
try {
const { fetchEvents: rawFetchEvents } = await import('./js/init-ndk.mjs');
const events = await rawFetchEvents({ kinds: CASHU_KINDS, authors: [currentPubkey] }, relay.url);
eventsByRelay[relay.url] = events || [];
completed++;
divValidationStatus.textContent = `Received ${completed}/${total} relays...`;
} catch (e) {
completed++;
}
}
console.log('[event.html] Fetched Cashu events by relay:', eventsByRelay);
groupCashuEvents();
populateCashuDropdown();
if (cashuEventGroups.size > 0) {
divValidationStatus.className = 'valid';
divValidationStatus.textContent = `✅ Found ${cashuEventGroups.size} Cashu event(s)`;
divCashuSelector.style.display = 'block';
} else {
divValidationStatus.className = 'info';
divValidationStatus.textContent = 'No Cashu wallet events found on any relay';
divEventList.style.display = 'none';
}
} catch (error) {
console.error('[event.html] Failed to fetch Cashu events:', error);
divValidationStatus.className = 'error';
divValidationStatus.textContent = `❌ Failed to fetch Cashu events: ${error.message}`;
divEventList.style.display = 'none';
}
}
/* ================================================================
EVENT PUBLISHING
================================================================ */
async function repostEvent() {
if (!currentEvent) return;
btnRepost.disabled = true;
btnResign.disabled = true;
divValidationStatus.className = 'info';
divValidationStatus.textContent = 'Reposting event...';
try {
const result = await publishEvent(currentEvent);
// Log publish results to console
console.log('[event.html] Repost result:', result);
console.log('[event.html] Successfully posted to:', result.relayResults.successful);
console.log('[event.html] Failed relays:', result.relayResults.failed);
divValidationStatus.className = 'valid';
divValidationStatus.textContent = `✅ Reposted to ${result.relayResults.successful.length}/${result.totalRelays} relays`;
// Refetch events from relays after a delay
setTimeout(async () => {
if (currentAddressableKey) {
await loadAddressableEvents();
} else if (currentCashuKey) {
await loadCashuWalletEvents();
} else {
await loadEvent(currentKind);
}
}, 2000);
} catch (error) {
divValidationStatus.className = 'error';
divValidationStatus.textContent = `❌ Failed to repost: ${error.message}`;
} finally {
btnRepost.disabled = false;
btnResign.disabled = false;
}
}
async function resignAndPost() {
if (!currentEvent) return;
btnRepost.disabled = true;
btnResign.disabled = true;
divValidationStatus.className = 'info';
divValidationStatus.textContent = 'Resigning and posting...';
try {
// Create new event with updated timestamp and no id/sig
const newEvent = {
kind: currentEvent.kind,
content: currentEvent.content,
created_at: Math.floor(Date.now() / 1000),
tags: currentEvent.tags,
pubkey: currentPubkey
};
const result = await publishEvent(newEvent);
// Log publish results to console
console.log('[event.html] Resign & Post result:', result);
console.log('[event.html] Successfully posted to:', result.relayResults.successful);
console.log('[event.html] Failed relays:', result.relayResults.failed);
divValidationStatus.className = 'valid';
divValidationStatus.textContent = `✅ Resigned and posted to ${result.relayResults.successful.length}/${result.totalRelays} relays`;
// Update the editor with the new event data (id and sig will be set by NDK)
txtEventJson.value = formatEventJson(newEvent);
updateValidationStatus();
// Refetch events from relays after a delay
setTimeout(async () => {
if (currentAddressableKey) {
await loadAddressableEvents();
} else if (currentCashuKey) {
await loadCashuWalletEvents();
} else {
await loadEvent(currentKind);
}
}, 2000);
} catch (error) {
divValidationStatus.className = 'error';
divValidationStatus.textContent = `❌ Failed to post: ${error.message}`;
} finally {
btnRepost.disabled = false;
btnResign.disabled = false;
}
}
/* ================================================================
FOOTER & LOGOUT
================================================================ */
const UpdateFooter = async () => {
try {
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
divFooterCenter.innerHTML = '';
divFooterRight.innerHTML = '';
} catch (error) {
console.error('[event.html] Error updating footer:', error);
}
};
const Logout = async () => {
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
disconnect();
if (window.NOSTR_LOGIN_LITE?.logout) {
await window.NOSTR_LOGIN_LITE.logout();
}
localStorage.clear();
sessionStorage.clear();
location.reload(true);
};
/* ================================================================
INITIALIZATION
================================================================ */
(async function main() {
try {
initHamburgerMenu();
document.getElementById('divSvgHam').addEventListener('click', toggleNav);
document.getElementById('themeToggleButton')?.addEventListener('click', () => {
isDarkMode = !isDarkMode;
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
window.location.reload();
});
document.getElementById('logoutButton')?.addEventListener('click', Logout);
// Kind button handlers
document.getElementById('btnKind0').addEventListener('click', () => loadEvent(0));
document.getElementById('btnKind3').addEventListener('click', () => loadEvent(3));
document.getElementById('btnKind10002').addEventListener('click', () => loadEvent(10002));
document.getElementById('btnCashuWallet').addEventListener('click', loadCashuWalletEvents);
document.getElementById('btnAddressable').addEventListener('click', loadAddressableEvents);
// Selector handlers
selCashu.addEventListener('change', onCashuSelected);
selAddressable.addEventListener('change', onAddressableSelected);
// Textarea input handler for validation
txtEventJson.addEventListener('input', updateValidationStatus);
// Action button handlers
btnRepost.addEventListener('click', repostEvent);
btnResign.addEventListener('click', resignAndPost);
await initNDKPage();
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);
console.log('[event.html] Authenticated as:', currentPubkey);
// Subscribe to user's editable kinds and Cashu (NIP-60) kinds
// This ensures newly published events are received and cached
const subscribedKinds = [0, 3, 10002, ...CASHU_KINDS];
console.log('[event.html] Setting up persistent subscriptions for kinds:', subscribedKinds);
const persistentSub = subscribe(
{
kinds: subscribedKinds,
authors: [currentPubkey]
},
{
closeOnEose: false // Keep subscription open to receive new events
}
);
console.log('[event.html] Persistent subscription created:', persistentSub.subId);
// Listen for events from the persistent subscription
window.addEventListener('ndkEvent', (e) => {
const evt = e.detail;
if (subscribedKinds.includes(evt.kind)) {
console.log(`[event.html] Received kind ${evt.kind} event via subscription:`, evt.id);
// Event will be automatically cached by NDK worker
}
});
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
// Listen for relay activity
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity } = event.detail;
setRelayActivityState(relayUrl, activity);
});
// Start update loop
updateIntervalId = setInterval(UpdateFooter, 1000);
document.getElementById('versionDisplay').textContent = VERSION;
console.log('[event.html] Initialization complete');
} catch (error) {
console.error('[event.html] Initialization failed:', error);
}
})();
</script>
</body>
</html>