1524 lines
54 KiB
HTML
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>
|