Files
client/www/old/bunker.html
T

1139 lines
43 KiB
HTML

<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>TEMPLATE</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>
</head>
<body>
<!-- ================================================================
HAMBURGER BUTTON (Fixed, separate from header)
================================================================
The hamburger button is a fixed element outside the header
to ensure it stays visible above the sidenav (z-index: 10 > 3).
================================================================ -->
<div id="divSvgHam" class="divHeaderButtons">
<!-- HamburgerMorphing will be injected here -->
</div>
<!-- ================================================================
HEADER
================================================================
Standard header with title (center).
================================================================ -->
<div id="divHeader">
<div id="divHeaderFlexLeft">
<!-- Hamburger is now separate fixed element -->
</div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText"></div>
</div>
<div id="divHeaderFlexRight">
<!-- No button in header right - logout is in sidenav footer -->
</div>
</div>
<!-- ================================================================
BODY
================================================================
Main content area. Add your page-specific content here.
================================================================ -->
<div id="divBody">
</div>
<!-- ================================================================
FOOTER
================================================================
Three-section footer layout:
- Left: Relay status animations (HamburgerMorphing instances)
- Center: General status information
- Right: Additional information
================================================================ -->
<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
================================================================
Slide-out navigation panel. Opens from left when hamburger clicked.
Uses flexbox layout to pin version bar to bottom.
Includes a version bar footer with theme toggle and logout buttons.
================================================================ -->
<div id="divSideNav">
<div id="divSideNavHeader">
<!-- No close button - use main hamburger to close -->
</div>
<div id="divSideNavBody">
<div id="divFiles"></div>
</div>
<div id="divAiSection" class="sidenavSection">
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
<div id="divAiList" class="sidenavSectionList">
<div id="divAiProvidersList">No saved providers yet.</div>
</div>
</div>
<div id="divRelaySection">
<div id="divRelaySectionTitle">
リレー
</div>
<div id="divRelayList">
Loading relays...
</div>
</div>
<div id="divBlossomSection">
<div id="divBlossomSectionTitle">ブロッサム</div>
<div id="divBlossomList">Loading blossom servers...</div>
</div>
<div id="divVersionBar">
<span id="versionDisplay">v0.0.1</span>
<div id="divVersionBarButtons">
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
<div id="themeToggleHamburgerContainer"></div>
</button>
<button id="logoutButton" title="Logout">
<div id="logoutHamburgerContainer"></div>
</button>
</div>
</div>
</div>
<!-- ================================================================
REQUIRED SCRIPTS
================================================================
These scripts must be loaded in this order:
1. nostr.bundle.js - Nostr tools library
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./ndk-core.bundle.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="text/javascript" src="./js/qrcode-generator.min.js"></script>
<script type="module">
/* ================================================================
IMPORTS
================================================================
Import shared NDK functionality from init-ndk.mjs:
- initNDKPage() - Initialize authentication and worker
- getPubkey() - Get current user's pubkey
- subscribe() - Create NDK subscriptions
- publishEvent() - Publish events via NDK
- disconnect() - Disconnect from worker
- getRelayData() - Get relay connection data
- getRelayStats() - Get relay activity statistics
Import HamburgerMorphing for animated icons
================================================================ */
import {
initNDKPage,
getPubkey, injectHeaderAvatar,
subscribe,
publishEvent,
disconnect,
getVersion,
updateVersionDisplay,
getUserSettings,
patchUserSettings,
onUserSettings,
getRelayData
} 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';
// Version will be loaded asynchronously
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
console.log(`[template.html ${VERSION}] Loading...`);
/* ================================================================
GLOBAL VARIABLES
================================================================
Track state for hamburger menu, relay status, and theme.
================================================================ */
let updateIntervalId = null;
let currentPubkey = null;
/*
AUTH STATE MODEL (Template reference)
------------------------------------------------------------------
This template now demonstrates three auth modes for standalone pages:
- required (default):
Behaves like existing pages: login is required immediately.
- optional:
Page can render public/read-only data without login, but can still
prompt login later for user actions (publish, settings, etc).
- none:
Never auto-login on load (pure public page).
URL behavior in this template:
- If ?auth=required|optional|none is present, it wins.
- Otherwise, if URL includes ?npub=... or ?pubkey=..., mode defaults
to optional because pages with explicit profile targets are commonly
public-readable.
- Otherwise, mode defaults to required.
*/
let isAuthenticated = false;
let authMode = 'required';
let authedPageInitialized = false;
let relayActivityListenersBound = false;
// Hamburger menu
let hamburgerInstance = null;
let isNavOpen = false;
// Version bar buttons
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
// App-wide user settings (NIP-78 kind 30078, d:user-settings)
let pageSettings = {};
let unsubscribeUserSettings = null;
// Bunker runtime state (main section feature)
let bunkerRunning = false;
let bunkerNdk = null;
let bunkerBackend = null;
let bunkerUri = '';
let bunkerRelayUrls = [];
let bunkerLog = [];
/* ================================================================
DOM VARIABLES
================================================================
Cache DOM element references for better performance.
================================================================ */
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");
/* ================================================================
HAMBURGER MENU
================================================================
Initialize and control the animated hamburger menu.
================================================================ */
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
/* ================================================================
SIDENAV FUNCTIONS
================================================================
Open/close sidenav with hamburger morphing animation.
================================================================ */
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = "clamp(400px, 50vw, 600px)";
isNavOpen = true;
if (hamburgerInstance) {
hamburgerInstance.animateTo('arrow_left');
}
// Initialize version bar buttons when sidenav opens (lazy load)
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)'
});
// Determine current theme
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();
}
}
/* ================================================================
AUTH MODE HELPERS
================================================================
These functions are meant as reusable guidance for future pages.
================================================================ */
function hasTargetPubkeyInUrl() {
const params = new URLSearchParams(window.location.search || '');
const npub = String(params.get('npub') || '').trim();
const pubkey = String(params.get('pubkey') || '').trim();
return Boolean(npub || pubkey);
}
function resolveAuthModeFromUrl() {
const params = new URLSearchParams(window.location.search || '');
const explicitAuth = String(params.get('auth') || '').trim().toLowerCase();
if (explicitAuth === 'required' || explicitAuth === 'optional' || explicitAuth === 'none') {
return explicitAuth;
}
// Convention: explicit target profiles are public-readable by default.
if (hasTargetPubkeyInUrl()) {
return 'optional';
}
return 'required';
}
function isAuthRequiredError(error) {
const message = String(error?.message || error || '').toLowerCase();
return message.includes('authentication required');
}
async function initializeAuthentication(mode) {
// required: existing behavior, throw if auth fails.
if (mode === 'required') {
await initNDKPage();
currentPubkey = await getPubkey();
isAuthenticated = true;
return;
}
// none: public page, no login attempt on load.
if (mode === 'none') {
isAuthenticated = false;
currentPubkey = null;
return;
}
// optional: try silent/normal init; if auth required, continue public.
try {
await initNDKPage();
currentPubkey = await getPubkey();
isAuthenticated = true;
} catch (error) {
if (isAuthRequiredError(error)) {
console.log('[template.html] Optional auth mode: continuing unauthenticated');
isAuthenticated = false;
currentPubkey = null;
return;
}
throw error;
}
}
async function initializeAuthenticatedPageFeatures() {
if (!isAuthenticated || authedPageInitialized) return;
await injectHeaderAvatar(currentPubkey);
console.log('[template.html] Authenticated as:', currentPubkey);
// Hydrate app-wide user settings for this page
try {
pageSettings = await getUserSettings();
} catch (error) {
console.warn('[template.html] getUserSettings failed:', error);
pageSettings = {};
}
// Subscribe to live user settings updates (cross-tab + publish echoes)
if (!unsubscribeUserSettings) {
unsubscribeUserSettings = onUserSettings((settings) => {
pageSettings = settings || {};
// TODO: Re-render page-specific UI from pageSettings here.
});
}
// Initialize relay-dependent UI only once authenticated.
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
await UpdateFooter();
if (!updateIntervalId) {
updateIntervalId = setInterval(UpdateFooter, 1000);
}
// Relay activity listeners only matter after worker init/auth.
if (!relayActivityListenersBound) {
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity, stats } = event.detail;
console.log(`[template.html] Relay activity: ${relayUrl} - ${activity}`, stats);
setRelayActivityState(relayUrl, activity);
});
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'relayActivity') {
const { relayUrl, activity } = event.data;
console.log(`[template.html] Relay activity: ${relayUrl} - ${activity}`);
setRelayActivityState(relayUrl, activity);
}
});
relayActivityListenersBound = true;
}
authedPageInitialized = true;
}
async function promptLoginIfNeeded() {
if (isAuthenticated) return true;
await initNDKPage();
currentPubkey = await getPubkey();
isAuthenticated = true;
await initializeAuthenticatedPageFeatures();
return true;
}
function shortHex(value = '', left = 10, right = 8) {
const v = String(value || '');
if (v.length <= left + right + 3) return v;
return `${v.slice(0, left)}...${v.slice(-right)}`;
}
function getLocalAuthSecret() {
const storageKey = 'nostr_login_lite_auth';
const raw = sessionStorage.getItem(storageKey) || localStorage.getItem(storageKey);
if (!raw) return { ok: false, message: 'No nostr-login-lite auth state found.' };
try {
const parsed = JSON.parse(raw);
if (!parsed || parsed.method !== 'local') {
return {
ok: false,
message: 'Bunker requires Local auth method (nsec/hex). Extension/NIP-46 auth does not expose your secret key.'
};
}
if (!parsed.secret || typeof parsed.secret !== 'string') {
return { ok: false, message: 'Local auth found, but secret key is missing.' };
}
return { ok: true, secret: parsed.secret.trim() };
} catch (error) {
return { ok: false, message: `Failed to parse auth state: ${error?.message || String(error)}` };
}
}
function normalizeRelayUrls(relayData) {
if (!Array.isArray(relayData)) return [];
const urls = relayData
.map((entry) => {
if (typeof entry === 'string') return entry;
if (entry && typeof entry.url === 'string') return entry.url;
return '';
})
.map((url) => String(url || '').trim())
.filter(Boolean)
.filter((url) => url.startsWith('ws://') || url.startsWith('wss://'));
return Array.from(new Set(urls));
}
function randomHex(bytes = 32) {
const arr = new Uint8Array(bytes);
crypto.getRandomValues(arr);
return Array.from(arr).map((b) => b.toString(16).padStart(2, '0')).join('');
}
const BUNKER_SECRET_KEY = 'nostr_bunker_persistent_secret';
function getOrCreateBunkerSecret() {
let secret = localStorage.getItem(BUNKER_SECRET_KEY);
if (!secret || secret.length !== 64) {
secret = randomHex(32);
localStorage.setItem(BUNKER_SECRET_KEY, secret);
console.log('[bunker] Generated new persistent secret');
} else {
console.log('[bunker] Reusing persisted secret');
}
return secret;
}
function buildBunkerUri(pubkey, relayUrls, secret) {
const url = new URL(`bunker://${pubkey}`);
relayUrls.forEach((relay) => url.searchParams.append('relay', relay));
url.searchParams.set('secret', secret);
return url.toString();
}
function addBunkerLog(method, remotePubkey, allowed, detail = '') {
bunkerLog.unshift({
at: new Date().toISOString(),
method: String(method || 'unknown'),
remotePubkey: String(remotePubkey || 'unknown'),
allowed: Boolean(allowed),
detail: String(detail || '')
});
bunkerLog = bunkerLog.slice(0, 200);
renderBunkerMainSection();
}
function renderBunkerQr(uri) {
const qrTarget = document.getElementById('bunkerQr');
if (!qrTarget) return;
if (!uri) {
qrTarget.innerHTML = '';
return;
}
try {
const qr = qrcode(0, 'M');
qr.addData(uri);
qr.make();
qrTarget.innerHTML = `<div style="display:inline-block;background:white;padding:10px;border-radius:8px;">${qr.createSvgTag(4, 2)}</div>`;
} catch (error) {
qrTarget.innerHTML = `<div style="color:red;">QR error: ${error?.message || String(error)}</div>`;
}
}
function renderBunkerMainSection(statusMessage = '') {
const rows = bunkerLog.map((item) => {
const at = new Date(item.at).toLocaleTimeString();
const verdict = item.allowed ? 'allow' : 'deny';
const detail = item.detail ? ` · ${item.detail}` : '';
return `<div style="padding:6px 0;border-bottom:1px solid var(--border-color, #ddd);word-break:break-word;overflow-wrap:anywhere;"><code>${at}</code> <strong>${item.method}</strong> ${shortHex(item.remotePubkey)} <span style="opacity:.75;">${verdict}${detail}</span></div>`;
}).join('');
divBody.innerHTML = `
<div style="max-width:980px;margin:20px auto;padding:12px;">
<div style="font-size:30px;font-weight:700;margin-bottom:8px;">NIP-46 NSec Bunker</div>
<div style="opacity:.85;margin-bottom:18px;line-height:1.45;">
This page runs a bunker signer using your local nsec auth. Share the generated <code>bunker://</code> URI with remote clients.
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;">
<button id="btnStartBunker" style="padding:10px 16px;font-size:15px;">${bunkerRunning ? 'Restart bunker' : 'Start bunker'}</button>
<button id="btnStopBunker" style="padding:10px 16px;font-size:15px;" ${bunkerRunning ? '' : 'disabled'}>Stop bunker</button>
<button id="btnCopyBunker" style="padding:10px 16px;font-size:15px;" ${bunkerUri ? '' : 'disabled'}>Copy bunker:// URI</button>
<button id="btnNewUri" style="padding:10px 16px;font-size:15px;" ${bunkerRunning ? '' : 'disabled'}>Generate New URI</button>
</div>
<div style="margin-bottom:8px;font-weight:700;">Connection URI</div>
<textarea readonly style="width:100%;min-height:88px;padding:10px;border-radius:8px;box-sizing:border-box;">${bunkerUri}</textarea>
<div style="margin-top:8px;min-height:20px;opacity:.85;">${statusMessage || (bunkerRunning ? `Running on ${bunkerRelayUrls.length} relays` : 'Bunker stopped')}</div>
<div style="margin-top:16px;">
<div style="margin-bottom:8px;font-weight:700;">QR</div>
<div id="bunkerQr"></div>
<div style="margin-top:16px;margin-bottom:8px;font-weight:700;">Incoming request log (auto-approve)</div>
<div style="max-height:300px;overflow:auto;padding:10px;border:1px solid var(--border-color, #ccc);border-radius:8px;font-size:13px;word-break:break-word;overflow-wrap:anywhere;">
${rows || '<div style="opacity:.75;">No requests yet.</div>'}
</div>
</div>
</div>
`;
renderBunkerQr(bunkerUri);
const btnStart = document.getElementById('btnStartBunker');
const btnStop = document.getElementById('btnStopBunker');
const btnCopy = document.getElementById('btnCopyBunker');
const btnNewUri = document.getElementById('btnNewUri');
if (btnStart) {
btnStart.addEventListener('click', async () => {
console.log('[bunker] Start button clicked', {
bunkerRunning,
hasNDK: Boolean(window.NDK),
currentPubkey
});
await startBunker();
});
}
if (btnStop) btnStop.addEventListener('click', stopBunker);
if (btnCopy) {
btnCopy.addEventListener('click', async () => {
if (!bunkerUri) return;
try {
await navigator.clipboard.writeText(bunkerUri);
renderBunkerMainSection('bunker:// URI copied to clipboard');
} catch (error) {
renderBunkerMainSection(`Copy failed: ${error?.message || String(error)}`);
}
});
}
if (btnNewUri) {
btnNewUri.addEventListener('click', async () => {
if (!bunkerRunning) return;
// Generate a new secret and save it
const newSecret = randomHex(32);
localStorage.setItem(BUNKER_SECRET_KEY, newSecret);
console.log('[bunker] Generated new persistent secret via button');
// Restart the bunker to apply the new secret
await startBunker();
renderBunkerMainSection('Generated new bunker:// URI');
});
}
}
async function stopBunker() {
bunkerRunning = false;
bunkerUri = '';
if (bunkerNdk?.pool?.destroy) {
try { bunkerNdk.pool.destroy(); } catch { }
}
if (bunkerNdk?.disconnect) {
try { await bunkerNdk.disconnect(); } catch { }
}
bunkerBackend = null;
bunkerNdk = null;
bunkerRelayUrls = [];
renderBunkerMainSection('Bunker stopped');
}
async function startBunker() {
console.log('[bunker] startBunker() called');
try {
renderBunkerMainSection('Starting bunker...');
await stopBunker();
console.log('[bunker] previous bunker stopped');
const authSecret = getLocalAuthSecret();
console.log('[bunker] auth secret check', { ok: authSecret.ok, message: authSecret.message || null });
if (!authSecret.ok) {
throw new Error(authSecret.message);
}
const relayData = await getRelayData();
console.log('[bunker] relayData from worker', relayData);
const relayUrls = normalizeRelayUrls(relayData);
console.log('[bunker] normalized relayUrls', relayUrls);
if (!relayUrls.length) {
throw new Error('No relay URLs available from your configured relay list.');
}
const ndkLib = window.NDK;
console.log('[bunker] window.NDK available', Boolean(ndkLib));
if (!ndkLib) throw new Error('window.NDK unavailable.');
const NDKClass = ndkLib.default || ndkLib.NDK || ndkLib;
const { NDKPrivateKeySigner, NDKNip46Backend } = ndkLib;
console.log('[bunker] classes', {
hasNDKClass: Boolean(NDKClass),
hasPrivateKeySigner: Boolean(NDKPrivateKeySigner),
hasNip46Backend: Boolean(NDKNip46Backend)
});
if (!NDKClass || !NDKPrivateKeySigner || !NDKNip46Backend) {
throw new Error('NDK bunker classes unavailable.');
}
const signer = new NDKPrivateKeySigner(authSecret.secret);
bunkerNdk = new NDKClass({ explicitRelayUrls: relayUrls });
console.log('[bunker] connecting bunker NDK (non-blocking)...');
bunkerNdk.connect()
.then(() => {
console.log('[bunker] bunker NDK connected');
})
.catch((connectError) => {
console.warn('[bunker] bunker NDK connect error (continuing):', connectError);
});
console.log('[bunker] bunker NDK connect initiated');
// Get (or create) the persistent secret for this bunker session.
// Must be done before creating the backend so applyToken can validate it.
const persistentSecret = getOrCreateBunkerSecret();
bunkerBackend = new NDKNip46Backend(
bunkerNdk,
signer,
async (params) => {
console.log('[bunker] permit callback', params);
addBunkerLog(params?.method, params?.pubkey, true);
return true;
},
relayUrls
);
// Override applyToken so that connect requests that include our
// persistent secret are accepted instead of throwing "connection
// token not supported". We simply validate the token matches
// the expected secret and do nothing else (pubkeyAllowed handles
// the actual allow/deny decision via permitCallback).
bunkerBackend.applyToken = async (pubkey, token) => {
if (token && token !== persistentSecret) {
console.warn('[bunker] applyToken: token mismatch', { received: token, expected: persistentSecret });
throw new Error('connection token not supported');
}
console.log('[bunker] applyToken: token accepted for', pubkey?.substring(0, 8));
};
console.log('[bunker] starting NIP-46 backend...');
await bunkerBackend.start();
console.log('[bunker] backend started');
const user = await signer.user();
bunkerRelayUrls = relayUrls;
bunkerUri = buildBunkerUri(user.pubkey, relayUrls, persistentSecret);
bunkerRunning = true;
console.log('[bunker] bunker URI generated', bunkerUri);
renderBunkerMainSection(`Bunker running for ${shortHex(user.pubkey)}`);
} catch (error) {
console.error('[bunker] startBunker failed', error);
bunkerRunning = false;
bunkerUri = '';
addBunkerLog('start', currentPubkey || 'local', false, error?.message || String(error));
renderBunkerMainSection(`Start failed: ${error?.message || String(error)}`);
}
}
async function initializeBunkerMainSection() {
renderBunkerMainSection();
const authSecret = getLocalAuthSecret();
if (!authSecret.ok) {
renderBunkerMainSection(authSecret.message);
return;
}
startBunker().catch((error) => {
console.error('[bunker] initializeBunkerMainSection start failed:', error);
});
}
/* ================================================================
UPDATE FOOTER
================================================================
Update footer sections with relay status, pubkey, and other info.
Called periodically by update loop.
================================================================ */
const UpdateFooter = async () => {
try {
// Update relay status visuals in footer and sidenav
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
// Clear center and right sections
divFooterCenter.innerHTML = '';
divFooterRight.innerHTML = '';
} catch (error) {
console.error('[template.html] Error updating footer:', error);
}
};
/* ================================================================
LOGOUT
================================================================
Complete logout process:
1. Stop update loop
2. Disconnect from NDK worker
3. Logout from nostr-login-lite
4. Clear all storage (localStorage, sessionStorage, IndexedDB)
5. Reload page
================================================================ */
const Logout = async () => {
console.log("[template.html] Starting logout process...");
// Stop the update loop
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
// Disconnect from worker
disconnect();
// Logout from nostr-login-lite
if (window.NOSTR_LOGIN_LITE && window.NOSTR_LOGIN_LITE.logout) {
await window.NOSTR_LOGIN_LITE.logout();
}
// Clear all storage
localStorage.clear();
sessionStorage.clear();
// Clear IndexedDB
if (window.indexedDB) {
const databases = await window.indexedDB.databases();
for (const db of databases) {
if (db.name) {
window.indexedDB.deleteDatabase(db.name);
}
}
}
await stopBunker();
console.log("[template.html] Logged out, reloading page");
location.reload(true);
};
/* ================================================================
EVENT LISTENERS
================================================================
Wire up UI interactions.
Main hamburger button click handler is set up in main() after initialization.
================================================================ */
/* ================================================================
SUBSCRIPTION EXAMPLE
================================================================
Example of how to subscribe to Nostr events:
const sub = subscribe(
{ kinds: [1], authors: [pubkey], limit: 10 },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
Cache usage options:
- 'CACHE_FIRST' - Check cache first, then relays
- 'ONLY_RELAY' - Only query relays
- 'ONLY_CACHE' - Only query cache
- 'PARALLEL' - Query cache and relays simultaneously
Listen for events via window events:
window.addEventListener('ndkEvent', (event) => {
const evt = event.detail;
console.log('Received event:', evt);
});
================================================================ */
/* ================================================================
PUBLISH EXAMPLE
================================================================
Example of how to publish a Nostr event:
const event = {
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: "Hello, Nostr!"
};
try {
const result = await publishEvent(event);
console.log("✅ Published to:", result.relayResults.successful);
console.log("❌ Failed:", result.relayResults.failed);
console.log("Total relays:", result.totalRelays);
} catch (error) {
console.error("Publish error:", error);
}
Note: Events are automatically signed by the NDK worker using
the message-based signer (which calls window.nostr.signEvent).
================================================================ */
/* ================================================================
USER SETTINGS EXAMPLE (NIP-78)
================================================================
Read/subscribe/write helper pattern for all pages:
// Read latest merged settings (cache + relay hydrated by worker)
const settings = await getUserSettings();
// Subscribe to cross-tab updates
const unsubscribe = onUserSettings((nextSettings) => {
// Re-render page from nextSettings
});
// Patch only your feature namespace
await patchUserSettings({
myFeature: {
someFlag: true
}
});
// On page teardown (if applicable)
// unsubscribe();
================================================================ */
/* ================================================================
INITIALIZATION
================================================================
Main initialization sequence:
1. Initialize hamburger menu
2. Set up hamburger click handler
3. Resolve auth mode from URL/query policy
4. Initialize authentication based on mode
5. Initialize authenticated-only features (if signed in)
6. Set up version bar button listeners
7. Restore sidenav state
8. Update version display
Notes:
- required mode = existing behavior (prompt login on load)
- optional mode = allow public load, login later on demand
- none mode = no auto-login on load
================================================================ */
(async function main() {
console.log("[template.html] Starting initialization...");
try {
// Initialize hamburger menu first
initHamburgerMenu();
// Add click handler to hamburger
const divSvgHam = document.getElementById('divSvgHam');
if (divSvgHam) {
divSvgHam.addEventListener('click', toggleNav);
}
// Initialize version bar buttons
const themeToggleButton = document.getElementById('themeToggleButton');
const logoutButton = document.getElementById('logoutButton');
if (themeToggleButton) {
themeToggleButton.addEventListener('click', () => {
isDarkMode = !isDarkMode;
if (isDarkMode) {
localStorage.setItem('theme', 'dark');
} else {
localStorage.setItem('theme', 'light');
}
// Save sidenav state before reload
localStorage.setItem('sidenavWasOpen', isNavOpen ? 'true' : 'false');
window.location.reload();
});
}
if (logoutButton) {
logoutButton.addEventListener('click', async () => {
try {
// In optional/none modes this doubles as a "Sign in" entry point.
if (!isAuthenticated) {
await promptLoginIfNeeded();
return;
}
await Logout();
} catch (error) {
console.error('Logout/login action failed:', error);
}
});
}
// Resolve and initialize page auth policy.
authMode = resolveAuthModeFromUrl();
console.log('[template.html] Resolved auth mode:', authMode);
await initializeAuthentication(authMode);
// Initialize authenticated features only when signed in.
await initializeAuthenticatedPageFeatures();
initializeBunkerMainSection().catch((error) => {
console.error('[bunker] initializeBunkerMainSection failed:', error);
});
/* ============================================================
EXAMPLE: Subscribe to events
============================================================
Uncomment to subscribe to user's notes:
const notesSub = subscribe(
{ kinds: [1], authors: [currentPubkey], limit: 10 },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
console.log("[template.html] Subscribed to kind 1");
============================================================ */
/* ============================================================
EXAMPLE: Listen for events from worker
============================================================
Uncomment to handle incoming events:
window.addEventListener('ndkEvent', (event) => {
const evt = event.detail;
console.log("[template.html] Received event:", evt.kind, evt.pubkey);
if (evt.pubkey === currentPubkey) {
if (evt.kind === 1) {
// Handle note event
console.log("Note:", evt.content);
}
}
});
============================================================ */
/* ============================================================
EXAMPLE: Listen for cached profile
============================================================
Uncomment to handle cached profile data:
window.addEventListener('ndkProfile', (event) => {
console.log("[template.html] Cached profile:", event.detail);
// event.detail contains profile object (name, about, etc.)
});
============================================================ */
// Restore sidenav state if it was open before theme toggle
const sidenavWasOpen = localStorage.getItem('sidenavWasOpen');
if (sidenavWasOpen === 'true') {
localStorage.removeItem('sidenavWasOpen');
openNav();
}
// Optional UX note for public mode pages.
if (!isAuthenticated && (authMode === 'optional' || authMode === 'none')) {
divFooterCenter.textContent = 'Public mode';
divFooterRight.textContent = 'Sign in from side menu for private features';
}
// Update version display
await updateVersionDisplay();
console.log('[template.html] Initialization complete');
} catch (error) {
console.error('[template.html] Initialization failed:', error);
divBody.innerHTML = `<div style="text-align: center; padding: 50px;">
<div style="font-size: 24px; margin-bottom: 20px; color: red;">❌ Authentication Error</div>
<div style="font-size: 16px; color: #666;">${error.message}</div>
<div style="margin-top: 20px;">
<button onclick="location.reload()" style="padding: 10px 20px; font-size: 16px;">Retry</button>
</div>
</div>`;
}
})();
/* ================================================================
WORKER MESSAGE TYPES
================================================================
The NDK worker can send these message types:
1. 'response' - Response to init/subscribe/publish requests
- data.profile - User profile (from init)
- data.relays - User relays (from init)
- data.success - Publish success status
- data.relayResults - Relay publish results
2. 'event' - Nostr event from subscription
- Dispatched as 'ndkEvent' window event
- event.detail contains the Nostr event
3. 'eose' - End of stored events for subscription
- Dispatched as 'ndkEose' window event
- event.detail.subId contains subscription ID
4. 'signRequest' - Request to sign event/encrypt/decrypt
- Handled automatically by init-ndk.mjs
- Calls window.nostr methods and sends response
5. 'error' - Error from worker
- Logged to console automatically
6. 'relayActivity' - Relay read/write activity notification
- Dispatched as 'ndkRelayActivity' window event
- Used to animate relay status icons in footer
================================================================ */
/* ================================================================
DISTRIBUTED ARCHITECTURE NOTES
================================================================
Each page is independently accessible and self-contained:
1. Authentication persists via nostr-login-lite localStorage
- Login once on any page
- All other pages automatically authenticated
2. NDK SharedWorker is shared across all tabs/pages
- Single NDK instance manages all connections
- Subscriptions from all pages handled by one worker
- Events broadcast to all connected pages
3. Dexie cache is shared across all pages
- IndexedDB persists across sessions
- Cache-first queries are fast
- Reduces relay load
4. User settings are centralized and shared
- Worker hydrates kind 30078 (`d:user-settings`) on init
- Pages read via getUserSettings()
- Pages patch via patchUserSettings({ featureNamespace: ... })
- Pages subscribe via onUserSettings() for live updates
5. Each page can be distributed independently
- Copy template.html and customize
- No dependencies on other pages
- Works standalone or as part of suite
6. Message-based signer bridges worker and page
- Worker's NDK uses MessageBasedSigner
- Signer sends sign requests to page
- Page calls window.nostr.signEvent()
- Response sent back to worker
- NDK completes signing and publishing
7. Relay status visualization
- Footer left section shows connected relays
- Each relay has animated icon (HamburgerMorphing)
- Icons morph based on activity (read/write)
- Temporary animations show real-time activity
================================================================ */
</script>
</body>
</html>