1(function () { 2 'use strict'; 3 4 var AUTH_KIND = 22242; 5 var NIP46_KIND = 24133; 6 var NIP46_RELAYS = [ 7 'wss://andersaamodt.com' 8 ]; 9 10 var IDB_DB_NAME = 'wizardry-blog-auth'; 11 var IDB_STORE_NAME = 'kv'; 12 var KEY_DEVICE_SESSION = 'nostr_device_session_v1'; 13 var KEY_NIP46_PAIR = 'nostr_nip46_pair_v1'; 14 var NAV_TOAST_KEY = 'wizardry_blog_nav_toast_v1'; 15 var NOSTR_PAGE_BOOTSTRAP_CACHE_PREFIX = 'nostr_page_bootstrap_v1:'; 16 var SITE_TITLE_CACHE_KEY = 'wizardry_blog_site_title_v1'; 17 var APPEND_SITE_TITLE_CACHE_KEY = 'wizardry_blog_append_site_title_to_page_title_v1'; 18 var THEME_CACHE_KEY = 'wizardry_blog_theme_v1'; 19 var PLUGINS_CACHE_KEY = 'wizardry_plugins_v1'; 20 var SITE_NOTIFICATION_CURSOR_PREFIX = 'wizardry_site_notification_cursor_v1:'; 21 var NOSTR_PAGE_PREFETCH_EXCLUDE = { 22 about: true, 23 blog: true, 24 admin: true, 25 compose: true, 26 account: true, 27 users: true 28 }; 29 30 var state = { 31 currentTheme: 'lapidarist', 32 isAuthenticated: false, 33 isAdmin: false, 34 plugins: { 35 nostr_support: true, 36 nostr_login: true, 37 nostr_bridge: true, 38 nostr_posts: true, 39 zaps: true, 40 btcpay: true, 41 video_chat: false 42 }, 43 manualChallenge: null, 44 idbPromise: null, 45 nip46: { 46 active: false, 47 appSecretHex: '', 48 appPubkey: '', 49 pairSecret: '', 50 signerPubkey: '', 51 accountPubkey: '', 52 relays: NIP46_RELAYS.slice(), 53 pool: null, 54 subscription: null, 55 pending: {}, 56 pendingTimers: {}, 57 seenEvents: {}, 58 toolsWaitPromise: null, 59 returnRefreshTimer: 0, 60 deepLinkFallbackTimer: 0, 61 lastReturnLoginAttemptAt: 0, 62 autoLoginInFlight: false, 63 diagnostics: { 64 eventsSeen: 0, 65 decryptErrors: 0, 66 ignoredSecrets: 0, 67 lastEventPubkey: '', 68 lastMessage: '' 69 } 70 }, 71 prefetchedNostrPageSlugs: {}, 72 navOverflowRaf: 0, 73 navOverflowTimer: 0, 74 videoCallPresenceTimer: 0, 75 videoCallNotification: null, 76 videoCallCurrentCallId: '', 77 videoCallAllowAdminCalls: false, 78 siteNotificationTimer: 0, 79 siteNotificationHost: null, 80 secureChatNotificationCursor: 0, 81 seenSiteNotifications: {}, 82 requestedNotificationPermission: false, 83 activeAuthTab: 'register', 84 activeAuthFlavor: 'desktop', 85 navOverflowReady: false 86 }; 87 88 if (!window.citrine || typeof window.citrine.web.ensureNostrLoginDialog !== 'function') { 89 throw new Error('citrine.web.ensureNostrLoginDialog is required for Nostr login'); 90 } 91 window.citrine.web.ensureNostrLoginDialog(document, { title: 'Sign in' }); 92 93 var els = { 94 loginBtn: document.getElementById('login-btn'), 95 loginSplit: document.getElementById('nav-login-split'), 96 loginMoreBtn: document.getElementById('login-more-btn'), 97 loginMenu: document.getElementById('nav-login-menu'), 98 loginMenuRegister: document.getElementById('login-menu-register'), 99 loginMenuPhone: document.getElementById('login-menu-phone'), 100 loginMenuManual: document.getElementById('login-menu-manual'), 101 loginMenuLearn: document.getElementById('login-menu-learn'), 102 navToastHost: document.getElementById('nav-top-toast-host'), 103 composeTools: document.getElementById('nav-compose-tools'), 104 composeLink: document.querySelector('.nav-compose'), 105 userMenu: document.getElementById('nav-user-menu'), 106 menuBtn: document.getElementById('nav-menu-btn'), 107 menuPanel: document.getElementById('nav-menu-panel'), 108 navOverflowMenu: document.getElementById('nav-overflow-menu'), 109 navOverflowBtn: document.getElementById('nav-overflow-btn'), 110 navOverflowCount: document.getElementById('nav-overflow-count'), 111 navOverflowPanel: document.getElementById('nav-overflow-panel'), 112 menuPrimaryLink: document.getElementById('nav-menu-primary-link'), 113 menuLogoutBtn: document.getElementById('nav-menu-logout'), 114 menuLogoutMoreBtn: document.getElementById('nav-menu-logout-more'), 115 menuLogoutSubmenu: document.getElementById('nav-menu-logout-submenu'), 116 menuLogoutEverywhereBtn: document.getElementById('nav-menu-logout-everywhere'), 117 userName: document.getElementById('nav-user-name'), 118 119 authModal: document.getElementById('auth-modal'), 120 authModalTitle: document.getElementById('auth-modal-title'), 121 authMessage: document.getElementById('auth-modal-message'), 122 authRegisterBtn: document.getElementById('auth-register-btn'), 123 authPhoneBtn: document.getElementById('auth-phone-btn'), 124 authTabRegister: document.getElementById('auth-tab-register'), 125 authTabPhone: document.getElementById('auth-tab-phone'), 126 authTabManual: document.getElementById('auth-tab-manual'), 127 authLoginSummary: document.getElementById('auth-login-summary'), 128 authLoginApps: document.getElementById('auth-login-apps'), 129 authLoginNote: document.getElementById('auth-login-note'), 130 authZapSummary: document.getElementById('auth-zap-summary'), 131 authZapApps: document.getElementById('auth-zap-apps'), 132 authZapNote: document.getElementById('auth-zap-note'), 133 134 authRegisterPanel: document.getElementById('auth-register-panel'), 135 authPhonePanel: document.getElementById('auth-phone-panel'), 136 authNip46Qr: document.getElementById('auth-nip46-qr'), 137 authNip46Uri: document.getElementById('auth-nip46-uri'), 138 authNip46Open: document.getElementById('auth-nip46-open'), 139 authNip46UriCopy: document.getElementById('auth-nip46-uri-copy'), 140 authNip46Reset: document.getElementById('auth-nip46-reset'), 141 authNip46Diagnostics: document.getElementById('auth-nip46-diagnostics'), 142 143 authManualPanel: document.getElementById('auth-manual-panel'), 144 authManualStart: document.getElementById('auth-manual-start'), 145 authManualRequestId: document.getElementById('auth-manual-request-id'), 146 authManualChallenge: document.getElementById('auth-manual-challenge'), 147 authManualExpires: document.getElementById('auth-manual-expires'), 148 authManualTemplate: document.getElementById('auth-manual-template'), 149 authManualEvent: document.getElementById('auth-manual-event'), 150 authManualSubmit: document.getElementById('auth-manual-submit') 151 }; 152 153 var authModalHideTimer = null; 154 var authMessageClearTimer = null; 155 var themeSwitchVisualTimer = null; 156 var themeSwapToken = 0; 157 var sessionCheckRetryTimer = 0; 158 var sessionCheckGraceToken = ''; 159 var sessionCheckGraceCount = 0; 160 161 function nowEpoch() { 162 return Math.floor(Date.now() / 1000); 163 } 164 165 function randomHex(bytesLen) { 166 var size = Number(bytesLen || 16); 167 var bytes = new Uint8Array(size); 168 window.crypto.getRandomValues(bytes); 169 return bytesToHex(bytes); 170 } 171 172 function bytesToHex(bytes) { 173 return Array.from(bytes || []).map(function (b) { 174 return b.toString(16).padStart(2, '0'); 175 }).join(''); 176 } 177 178 function hexToBytes(hex) { 179 var raw = String(hex || '').trim(); 180 if (!/^[0-9a-fA-F]+$/.test(raw) || raw.length % 2 !== 0) { 181 throw new Error('Invalid hex input'); 182 } 183 var out = new Uint8Array(raw.length / 2); 184 for (var i = 0; i < raw.length; i += 2) { 185 out[i / 2] = parseInt(raw.slice(i, i + 2), 16); 186 } 187 return out; 188 } 189 190 function compact(text) { 191 return String(text || '').replace(/\s+/g, ' ').trim(); 192 } 193 194 function shortPubkey(value) { 195 var raw = String(value || '').trim(); 196 if (!raw) { 197 return ''; 198 } 199 return raw.slice(0, 8); 200 } 201 202 function normalizePlugins(raw) { 203 var src = raw && typeof raw === 'object' ? raw : {}; 204 var normalized = { 205 nostr_support: src.nostr_support !== false, 206 nostr_login: src.nostr_login !== false, 207 nostr_bridge: src.nostr_bridge !== false, 208 nostr_posts: src.nostr_posts !== false, 209 zaps: src.zaps !== false, 210 btcpay: src.btcpay !== false, 211 video_chat: src.video_chat === true 212 }; 213 if (!normalized.nostr_support) { 214 normalized.nostr_login = false;
215 normalized.nostr_bridge = false; 216 normalized.nostr_posts = false; 217 normalized.zaps = false; 218 } 219 return normalized; 220 } 221 222 function cachePlugins(plugins) { 223 try { 224 localStorage.setItem(PLUGINS_CACHE_KEY, JSON.stringify(plugins || {})); 225 } catch (_err) { 226 // Ignore storage failures. 227 } 228 } 229 230 function readCachedPlugins() { 231 try { 232 var raw = localStorage.getItem(PLUGINS_CACHE_KEY); 233 if (!raw) { 234 return null; 235 } 236 return normalizePlugins(JSON.parse(raw)); 237 } catch (_err) { 238 return null; 239 } 240 } 241 242 function publishPlugins(plugins) { 243 state.plugins = normalizePlugins(plugins); 244 cachePlugins(state.plugins); 245 window.__wizardryPlugins = state.plugins; 246 window.__wizardryVideoChatEnabled = !!state.plugins.video_chat; 247 try { 248 window.dispatchEvent(new CustomEvent('wizardry-plugins-ready', { 249 detail: { plugins: state.plugins } 250 })); 251 } catch (_err) { 252 // Ignore event dispatch failures. 253 } 254 syncVideoCallPresencePolling(); 255 } 256 257 function syncPluginAuthUi() { 258 if (state.isAuthenticated) { 259 return; 260 } 261 var loginEnabled = !(state.plugins && state.plugins.nostr_login === false); 262 if (!loginEnabled) { 263 if (els.loginSplit) { 264 els.loginSplit.style.display = 'none'; 265 } else if (els.loginBtn) { 266 els.loginBtn.style.display = 'none'; 267 } 268 return; 269 } 270 if (els.loginSplit) { 271 els.loginSplit.style.display = ''; 272 } 273 if (els.loginBtn) { 274 els.loginBtn.style.display = ''; 275 } 276 } 277 278 function normalizeSiteTitle(value) { 279 var text = compact(value); 280 return text || 'Site'; 281 } 282 283 function normalizeThemeName(value) { 284 var text = compact(value); 285 return text || 'lapidarist'; 286 } 287 288 function updateNavSiteSignature(title) { 289 var node = document.getElementById('nav-site-signature'); 290 if (!node) { 291 return; 292 } 293 var next = normalizeSiteTitle(title); 294 if (node.textContent !== next) { 295 node.textContent = next; 296 } 297 node.setAttribute('title', next); 298 } 299 300 function syncNavSiteSignatureDestination() { 301 var node = document.getElementById('nav-site-signature'); 302 if (!node || !node.setAttribute) { 303 return; 304 }
305 var firstNavLink = document.querySelector('.nav-center a[data-page][href]'); 306 var href = firstNavLink ? compact(firstNavLink.getAttribute('href') || '') : ''; 307 node.setAttribute('href', href || '/'); 308 } 309 310 function cacheSiteTitle(title) { 311 try { 312 localStorage.setItem(SITE_TITLE_CACHE_KEY, normalizeSiteTitle(title)); 313 } catch (_err) { 314 // Ignore storage failures. 315 } 316 } 317 318 function normalizeAppendSiteTitleEnabled(value) { 319 return value === true || value === 'true' || value === 1 || value === '1'; 320 } 321 322 function cacheAppendSiteTitleEnabled(enabled) { 323 try { 324 localStorage.setItem(APPEND_SITE_TITLE_CACHE_KEY, normalizeAppendSiteTitleEnabled(enabled) ? '1' : '0'); 325 } catch (_err) { 326 // Ignore storage failures. 327 } 328 } 329 330 function cacheTheme(theme) { 331 try { 332 localStorage.setItem(THEME_CACHE_KEY, normalizeThemeName(theme)); 333 } catch (_err) { 334 // Ignore storage failures. 335 } 336 } 337 338 function readSiteBootstrap() { 339 var bootstrap = window.__wizardrySiteBootstrap; 340 if (!bootstrap || typeof bootstrap !== 'object') { 341 return null; 342 } 343 return bootstrap; 344 } 345 346 function readBootstrapConfig() { 347 var bootstrap = readSiteBootstrap(); 348 if (!bootstrap || !bootstrap.config || typeof bootstrap.config !== 'object') { 349 return null; 350 } 351 return bootstrap.config; 352 } 353 354 function readBootstrapNavbarPages() { 355 var bootstrap = readSiteBootstrap(); 356 if (!bootstrap || !Array.isArray(bootstrap.navbar_pages) || !bootstrap.navbar_pages.length) { 357 return null; 358 } 359 return bootstrap.navbar_pages; 360 } 361 362 function applySiteConfig(data) { 363 var next = data && typeof data === 'object' ? data : {}; 364 var appendSiteTitle = normalizeAppendSiteTitleEnabled(next.append_site_title_to_page_title); 365 updateNavSiteSignature(next.site_title || ''); 366 cacheSiteTitle(next.site_title || ''); 367 cacheAppendSiteTitleEnabled(appendSiteTitle); 368 window.__wizardrySetPageTitleConfig({ 369 siteTitle: next.site_title || '', 370 appendSiteTitle: appendSiteTitle 371 }); 372 if (Object.prototype.hasOwnProperty.call(next, 'theme')) { 373 state.currentTheme = normalizeThemeName(next.theme); 374 cacheTheme(state.currentTheme); 375 } 376 if (Object.prototype.hasOwnProperty.call(next, 'plugins')) { 377 publishPlugins(next.plugins || {}); 378 } 379 syncPluginAuthUi(); 380 } 381 382 function composeIconSvgPaths() { 383 return [ 384 { 385 id: "custom:quill-icon-32", 386 name: "quill-icon-32",
387 viewBox: "0 0 256 256", 388 body: '<path fill="currentColor" d="m229.66 58.34l-32-32a8 8 0 0 0-11.32 0l-96 96A8 8 0 0 0 88 128v32a8 8 0 0 0 8 8h32a8 8 0 0 0 5.66-2.34l96-96a8 8 0 0 0 0-11.32M124.69 152H104v-20.69l64-64L188.69 88ZM200 76.69L179.31 56L192 43.31L212.69 64ZM224 128v80a16 16 0 0 1-16 16H48a16 16 0 0 1-16-16V48a16 16 0 0 1 16-16h80a8 8 0 0 1 0 16H48v160h160v-80a8 8 0 0 1 16 0"/>' 389 } 390 ]; 391 } 392 393 function readComposeIconIndex() { 394 return 0; 395 } 396 397 function renderComposeIcon(index) { 398 if (!els.composeLink) { 399 return; 400 } 401 var icons = composeIconSvgPaths(); 402 var count = icons.length; 403 var idx = Number(index || 0); 404 if (!isFinite(idx) || idx < 0) { 405 idx = 0; 406 } 407 idx = idx % count; 408 var icon = icons[idx]; 409 var body = typeof icon === 'string' ? icon : (icon && icon.body ? icon.body : ''); 410 var src = (icon && icon.src) ? String(icon.src) : ''; 411 var viewBox = (icon && icon.viewBox) ? icon.viewBox : '0 0 24 24'; 412 if (src) { 413 els.composeLink.innerHTML = '<img src="' + src + '" width="21" height="21" alt="" aria-hidden="true">'; 414 } else { 415 els.composeLink.innerHTML = '<svg width="21" height="21" viewBox="' + viewBox + '" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">' + body + '</svg>'; 416 } 417 } 418 419 function waitMs(ms) { 420 var delay = Number(ms || 0); 421 if (!isFinite(delay) || delay < 0) { 422 delay = 0; 423 } 424 return new Promise(function (resolve) { 425 setTimeout(resolve, delay); 426 }); 427 } 428 429 function currentHost() { 430 return window.location.host; 431 } 432 433 function currentOrigin() { 434 return window.location.origin; 435 } 436 437 function currentAuthSignatureForCache() { 438 try { 439 var sessionToken = String(localStorage.getItem('session_token') || '').trim(); 440 var csrfToken = String(localStorage.getItem('csrf_token') || '').trim(); 441 return sessionToken + '|' + csrfToken; 442 } catch (_err) { 443 return '|'; 444 } 445 } 446 447 function writeNostrPageBootstrapCache(slug, payload) { 448 var safeSlug = String(slug || '').trim(); 449 if (!safeSlug || !payload || typeof payload !== 'object') { 450 return; 451 } 452 try { 453 localStorage.setItem(NOSTR_PAGE_BOOTSTRAP_CACHE_PREFIX + safeSlug, JSON.stringify({ 454 auth_signature: currentAuthSignatureForCache(), 455 payload: payload, 456 saved_at: Date.now() 457 })); 458 } catch (_err) { 459 // Ignore cache write failures. 460 } 461 } 462 463 function slugFromHref(href) { 464 var raw = String(href || '').trim(); 465 if (!raw || raw.indexOf('javascript:') === 0) { 466 return ''; 467 } 468 try { 469 var url = new URL(raw, window.location.href); 470 if (url.origin !== window.location.origin) { 471 return ''; 472 } 473 var path = String(url.pathname || '').replace(/\/+$/, ''); 474 if (!path || path === '/') { 475 return 'index'; 476 } 477 var slug = path; 478 if (slug.indexOf('/pages/') === 0) { 479 slug = slug.slice('/pages/'.length); 480 } else if (slug.indexOf('/') === 0) { 481 slug = slug.slice(1); 482 } 483 slug = slug.replace(/\.html?$/i, ''); 484 if (!slug) { 485 slug = 'index'; 486 } 487 return slug; 488 } catch (_err) { 489 return ''; 490 } 491 } 492 493 function prefetchNostrPageBootstrap(slug) { 494 var safeSlug = String(slug || '').trim(); 495 if (!safeSlug || NOSTR_PAGE_PREFETCH_EXCLUDE[safeSlug] || state.prefetchedNostrPageSlugs[safeSlug]) { 496 return; 497 } 498 state.prefetchedNostrPageSlugs[safeSlug] = true; 499 var body = new URLSearchParams({ 500 page_slug: safeSlug 501 }); 502 fetch('/cgi/blog-get-nostr-page', { 503 method: 'POST', 504 cache: 'no-store', 505 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 506 body: body.toString() 507 }) 508 .then(function (res) { return res.text(); }) 509 .then(function (text) { 510 var data; 511 try { 512 data = JSON.parse(text); 513 } catch (_parseErr) { 514 return; 515 } 516 if (!data || data.success === false) { 517 return; 518 } 519 writeNostrPageBootstrapCache(safeSlug, data); 520 }) 521 .catch(function () { 522 // Ignore prefetch errors. 523 }); 524 } 525 526 function bindNavbarNostrPagePrefetch() {
527 var navCenter = document.querySelector('.nav-center'); 528 if (!navCenter) { 529 return; 530 } 531 532 function triggerFromTarget(target) { 533 if (!target || !target.closest) { 534 return; 535 } 536 var link = target.closest('a[href]'); 537 if (!link) { 538 return; 539 } 540 var slug = slugFromHref(link.getAttribute('href') || ''); 541 if (!slug) { 542 return; 543 } 544 prefetchNostrPageBootstrap(slug); 545 } 546 547 navCenter.addEventListener('mouseover', function (event) { 548 triggerFromTarget(event.target); 549 }); 550 navCenter.addEventListener('focusin', function (event) { 551 triggerFromTarget(event.target); 552 }); 553 navCenter.addEventListener('touchstart', function (event) { 554 triggerFromTarget(event.target); 555 }, { passive: true }); 556 } 557 558 function warmNavbarNostrPagePrefetch() { 559 // Intentionally disabled: this eager fanout added avoidable concurrent 560 // bootstrap CGI calls during first paint. Keep prefetch on direct user 561 // intent (hover/focus/touch) via bindNavbarNostrPagePrefetch(). 562 return; 563 } 564 565 function hasNostrTools() { 566 return window.citrine.nostr.hasNostrTools(window); 567 } 568 569 function waitForNostrTools(timeoutMs) { 570 if (hasNostrTools()) { 571 state.nip46.toolsWaitPromise = null; 572 return Promise.resolve(); 573 } 574 if (state.nip46.toolsWaitPromise) { 575 return state.nip46.toolsWaitPromise; 576 } 577 var timeout = Number(timeoutMs || 8000); 578 if (!isFinite(timeout) || timeout < 1000) { 579 timeout = 8000; 580 } 581 state.nip46.toolsWaitPromise = window.citrine.nostr.waitForNostrTools({ 582 target: window, 583 document: document, 584 timeoutMs: timeout, 585 errorMessage: 'Phone signer setup is still loading. The browser may have blocked the Nostr tools script. Advanced signed JSON remains available.' 586 }).then(function () { 587 state.nip46.toolsWaitPromise = null; 588 }).catch(function (err) { 589 state.nip46.toolsWaitPromise = null; 590 throw err; 591 }); 592 return state.nip46.toolsWaitPromise; 593 } 594 595 function getBrowserSigner() { 596 try { 597 return window.citrine.nostr.getNip07Signer(window); 598 } catch (_err) { 599 throw new Error('No browser signer detected. Install nos2x-fox or use phone/manual login.'); 600 } 601 } 602 603 function ensureAuthMessageEl() { 604 if (!els.authModal || !els.authModal.querySelector) { 605 return null; 606 } 607 var panel = els.authModal.querySelector('.auth-modal-panel'); 608 if (!panel) { 609 return null; 610 } 611 var node = (els.authMessage && els.authMessage.isConnected) ? els.authMessage : panel.querySelector('#auth-modal-message'); 612 var isNew = !node; 613 if (isNew) { 614 node = document.createElement('div'); 615 } 616 node.id = 'auth-modal-message'; 617 if (isNew) { 618 node.className = 'auth-modal-message'; 619 node.setAttribute('aria-live', 'polite'); 620 } 621 var platformGrid = panel.querySelector('.auth-platform-grid'); 622 if (platformGrid && platformGrid.parentNode) { 623 platformGrid.parentNode.insertBefore(node, platformGrid); 624 } else { 625 panel.appendChild(node); 626 } 627 els.authMessage = node; 628 return els.authMessage; 629 } 630 631 function setAuthMessage(message, kind) { 632 var target = ensureAuthMessageEl(); 633 if (!target) { 634 return; 635 } 636 var text = String(message || ''); 637 if (authMessageClearTimer) { 638 clearTimeout(authMessageClearTimer); 639 authMessageClearTimer = null; 640 } 641 if (!text) { 642 target.classList.remove('is-visible'); 643 authMessageClearTimer = setTimeout(function () { 644 if (!target.classList.contains('is-visible')) { 645 target.textContent = ''; 646 target.className = 'auth-modal-message'; 647 } 648 authMessageClearTimer = null; 649 }, 220); 650 return; 651 } 652 target.textContent = text; 653 target.className = 'auth-modal-message'; 654 if (kind) { 655 target.classList.add('is-' + kind); 656 } 657 requestAnimationFrame(function () { 658 target.classList.add('is-visible'); 659 }); 660 setTimeout(function () { 661 target.classList.add('is-visible'); 662 }, 20); 663 } 664 665 function setNip46Diagnostics(message, kind) { 666 if (!els.authNip46Diagnostics) { 667 return; 668 } 669 var text = String(message || ''); 670 els.authNip46Diagnostics.textContent = text; 671 els.authNip46Diagnostics.className = 'auth-nip46-diagnostics'; 672 if (text && kind) { 673 els.authNip46Diagnostics.classList.add('is-' + kind); 674 } 675 state.nip46.diagnostics.lastMessage = text; 676 } 677 678 function isPhonePairingPanelActive() { 679 return state.activeAuthTab === 'phone' && !!els.authPhonePanel && !els.authPhonePanel.hidden; 680 } 681 682 function rememberNavToast(message, tone, durationMs) { 683 try { 684 sessionStorage.setItem(NAV_TOAST_KEY, JSON.stringify({ 685 message: String(message || ''), 686 tone: String(tone || 'info'), 687 durationMs: Number(durationMs || 3600), 688 at: Date.now() 689 })); 690 } catch (_err) { 691 // Ignore storage write failures; in-place toasts still work. 692 } 693 } 694 695 function showNavToast(message, tone, durationMs) { 696 var text = String(message || '').trim(); 697 if (!text) { 698 return; 699 } 700 var host = els.navToastHost; 701 if (!host) { 702 host = document.createElement('div'); 703 host.id = 'nav-top-toast-host'; 704 host.className = 'nav-top-toast-host'; 705 host.setAttribute('aria-live', 'polite'); 706 host.setAttribute('aria-atomic', 'true'); 707 document.body.appendChild(host); 708 els.navToastHost = host; 709 } 710 host.innerHTML = ''; 711 var toast = document.createElement('div'); 712 toast.className = 'nav-top-toast'; 713 if (tone) { 714 toast.classList.add('is-' + String(tone)); 715 } 716 toast.textContent = text; 717 host.appendChild(toast); 718 requestAnimationFrame(function () { 719 toast.classList.add('is-visible'); 720 }); 721 var stay = Number(durationMs || 3600); 722 if (!isFinite(stay) || stay < 1200) { 723 stay = 3600; 724 } 725 setTimeout(function () { 726 toast.classList.add('is-closing'); 727 setTimeout(function () { 728 if (toast.parentNode) { 729 toast.parentNode.removeChild(toast); 730 } 731 }, 230); 732 }, stay); 733 } 734 735 function flushRememberedNavToast() { 736 var raw = ''; 737 try { 738 raw = sessionStorage.getItem(NAV_TOAST_KEY) || ''; 739 if (raw) { 740 sessionStorage.removeItem(NAV_TOAST_KEY); 741 } 742 } catch (_err) { 743 raw = ''; 744 } 745 if (!raw) { 746 return; 747 } 748 try { 749 var payload = JSON.parse(raw); 750 if (!payload || typeof payload !== 'object') { 751 return; 752 } 753 showNavToast(payload.message || '', payload.tone || 'info', payload.durationMs || 3600); 754 } catch (_err2) { 755 // Ignore malformed persisted toasts. 756 } 757 } 758 759 function requestSignerApproval(signEventFn, template, waitingMessage, timeoutMs) { 760 var waitText = String(waitingMessage || 'Waiting for signer approval...'); 761 var timeout = Number(timeoutMs || 70000); 762 if (!isFinite(timeout) || timeout < 1000) { 763 timeout = 70000; 764 } 765 var settled = false;
766 var hintTimer = setTimeout(function () { 767 if (settled) { 768 return; 769 } 770 setAuthMessage(waitText + ' If the signer window is already open, switch to it and approve.', 'warn'); 771 try { 772 if (typeof window.focus === 'function') { 773 window.focus(); 774 } 775 } catch (_focusErr) { 776 // noop 777 } 778 }, 1200); 779 var timeoutTimer = setTimeout(function () { 780 if (settled) { 781 return; 782 } 783 settled = true; 784 clearTimeout(hintTimer); 785 }, timeout); 786 return Promise.resolve(signEventFn(template)).then(function (result) { 787 if (settled) { 788 throw new Error('Signer approval timed out.'); 789 } 790 settled = true; 791 clearTimeout(hintTimer); 792 clearTimeout(timeoutTimer); 793 return result; 794 }).catch(function (err) { 795 clearTimeout(hintTimer); 796 clearTimeout(timeoutTimer); 797 if (settled && (!err || !err.message)) { 798 throw new Error('Signer approval timed out.'); 799 } 800 throw err; 801 }); 802 } 803 804 function setAuthControlsDisabled(disabled) { 805 var isDisabled = !!disabled; 806 [ 807 els.authRegisterBtn, 808 els.authPhoneBtn, 809 els.authManualStart, 810 els.authManualSubmit, 811 els.authTabRegister, 812 els.authTabPhone, 813 els.authTabManual 814 ].forEach(function (node) { 815 if (node) { 816 node.disabled = isDisabled; 817 } 818 }); 819 if (els.authModal && typeof els.authModal.querySelectorAll === 'function') { 820 els.authModal.querySelectorAll('[data-auth-route]').forEach(function (node) { 821 node.disabled = isDisabled; 822 }); 823 } 824 if (!isDisabled) { 825 updatePhoneContinueState(); 826 } 827 } 828 829 function updatePhoneContinueState() { 830 var paired = !!state.nip46.signerPubkey; 831 var controls = els.authPhonePanel && els.authPhonePanel.querySelector 832 ? els.authPhonePanel.querySelector('.auth-nip46-controls') 833 : null; 834 var linkActions = els.authPhonePanel && els.authPhonePanel.querySelector 835 ? els.authPhonePanel.querySelector('.auth-nip46-link-actions') 836 : null; 837 if (controls) { 838 controls.classList.toggle('is-paired', paired); 839 } 840 if (linkActions) { 841 linkActions.hidden = paired; 842 } 843 if (els.authNip46Qr) { 844 els.authNip46Qr.hidden = paired; 845 } 846 if (paired && !hasPendingNip46Requests()) { 847 setNip46Diagnostics('Signer connected. Continue sign-in.', 'ok'); 848 setAuthMessage('Phone signer is already paired. Continue sign-in and approve the login request in your signer.', 'plain'); 849 } 850 if (!els.authPhoneBtn) { 851 return; 852 } 853 els.authPhoneBtn.disabled = !paired; 854 els.authPhoneBtn.setAttribute('aria-disabled', paired ? 'false' : 'true'); 855 els.authPhoneBtn.hidden = !paired; 856 els.authPhoneBtn.textContent = 'Continue sign-in'; 857 } 858 859 function resetAuthPanels() { 860 showPanel(els.authRegisterPanel, false); 861 showPanel(els.authPhonePanel, false); 862 showPanel(els.authManualPanel, false); 863 state.manualChallenge = null; 864 if (els.authManualRequestId) { els.authManualRequestId.value = ''; } 865 if (els.authManualChallenge) { els.authManualChallenge.value = ''; } 866 if (els.authManualExpires) { els.authManualExpires.value = ''; } 867 if (els.authManualTemplate) { els.authManualTemplate.value = ''; } 868 if (els.authManualEvent) { els.authManualEvent.value = ''; } 869 } 870 871 function authRecommendationIconAssets() { 872 return { 873 amber: '/static/icons/apps/amber.svg', 874 amethyst: '/static/icons/apps/amethyst.png', 875 damus: '/static/icons/apps/damus.png', 876 nostur: '/static/icons/apps/nostur.png', 877 nos2x: '/static/icons/apps/nos2x-fox.svg', 878 zeus: '/static/icons/apps/zeus.png' 879 }; 880 } 881 882 function renderAuthOnboarding(tabName, flavor) { 883 window.citrine.web.renderNostrRecommendations({ 884 tab: tabName, 885 flavor: flavor, 886 loginSummary: els.authLoginSummary, 887 loginApps: els.authLoginApps, 888 loginNote: els.authLoginNote, 889 zapSummary: els.authZapSummary, 890 zapApps: els.authZapApps, 891 zapNote: els.authZapNote, 892 iconAssets: authRecommendationIconAssets() 893 }); 894 } 895 896 function updateAuthPlatformCards(tab, flavor) { 897 if (!els.authModal || typeof els.authModal.querySelectorAll !== 'function') { 898 return; 899 }
900 var activeTab = String(tab || 'register'); 901 var activeFlavor = String(flavor || ''); 902 els.authModal.querySelectorAll('[data-auth-route]').forEach(function (button) { 903 var route = String(button.getAttribute('data-auth-route') || ''); 904 var cardFlavor = String(button.getAttribute('data-auth-flavor') || ''); 905 var active = route === activeTab && (!activeFlavor || cardFlavor === activeFlavor || (activeTab !== 'phone' && !activeFlavor)); 906 button.classList.toggle('is-active', active); 907 button.setAttribute('aria-pressed', active ? 'true' : 'false'); 908 }); 909 } 910 911 function setActiveAuthTab(tabName, phoneFlavor) { 912 var tab = String(tabName || 'register'); 913 if (tab !== 'register' && tab !== 'phone' && tab !== 'manual') { 914 tab = 'register'; 915 } 916 var flavor = String(phoneFlavor || (tab === 'phone' ? 'android' : tab)).trim() || tab; 917 state.activeAuthTab = tab; 918 state.activeAuthFlavor = flavor; 919 if (els.authModalTitle) { 920 els.authModalTitle.textContent = 'Sign in'; 921 } 922 updateAuthPlatformCards(tab, flavor); 923 renderAuthOnboarding(tab, flavor); 924 925 if (els.authTabRegister) { 926 var activeRegister = tab === 'register'; 927 els.authTabRegister.classList.toggle('is-active', activeRegister); 928 els.authTabRegister.setAttribute('aria-pressed', activeRegister ? 'true' : 'false'); 929 } 930 if (els.authTabPhone) { 931 var activePhone = tab === 'phone' && flavor === 'android'; 932 els.authTabPhone.classList.toggle('is-active', activePhone); 933 els.authTabPhone.setAttribute('aria-pressed', activePhone ? 'true' : 'false'); 934 } 935 if (els.authTabManual) { 936 var activeManual = tab === 'manual'; 937 els.authTabManual.classList.toggle('is-active', activeManual); 938 els.authTabManual.setAttribute('aria-pressed', activeManual ? 'true' : 'false'); 939 } 940 941 showPanel(els.authRegisterPanel, tab === 'register'); 942 showPanel(els.authPhonePanel, tab === 'phone'); 943 showPanel(els.authManualPanel, tab === 'manual'); 944 945 if (tab === 'phone') { 946 updatePhoneContinueState(); 947 setAuthMessage(window.citrine.web.signInHelperMessage(tab), 'plain'); 948 initNip46Pairing().then(function () { 949 updatePhoneContinueState(); 950 }).catch(function (err) { 951 setAuthMessage(err.message || 'Unable to prepare phone signer QR.', 'error'); 952 }); 953 return; 954 } 955 if (tab === 'manual') { 956 setAuthMessage(window.citrine.web.signInHelperMessage(tab), 'plain'); 957 return; 958 } 959 setAuthMessage(window.citrine.web.signInHelperMessage(tab), 'plain'); 960 } 961 962 function showAuthModal(initialTab) { 963 if (!els.authModal) { 964 return; 965 } 966 var initialSelection = normalizeAuthInitialSelection(initialTab); 967 syncAuthModalNavOffset(); 968 if (authModalHideTimer) { 969 clearTimeout(authModalHideTimer); 970 authModalHideTimer = null; 971 } 972 els.authModal.hidden = false; 973 requestAnimationFrame(function () { 974 syncAuthModalNavOffset(); 975 els.authModal.classList.add('is-open'); 976 }); 977 document.body.classList.add('auth-modal-open'); 978 resetAuthPanels(); 979 setAuthControlsDisabled(false); 980 setActiveAuthTab(initialSelection.tab, initialSelection.flavor); 981 } 982 983 function hideAuthModal() { 984 if (!els.authModal) { 985 return; 986 } 987 els.authModal.classList.remove('is-open'); 988 document.body.classList.remove('auth-modal-open'); 989 setAuthMessage('', ''); 990 setAuthControlsDisabled(false); 991 if (authModalHideTimer) { 992 clearTimeout(authModalHideTimer); 993 } 994 authModalHideTimer = setTimeout(function () { 995 if (!els.authModal.classList.contains('is-open')) { 996 els.authModal.hidden = true; 997 } 998 authModalHideTimer = null; 999 }, 210); 1000 } 1001 1002 function syncAuthModalNavOffset() { 1003 if (!els.authModal) { 1004 return; 1005 } 1006 var offset = 0; 1007 if (window.matchMedia && window.matchMedia('(max-width: 640px)').matches) { 1008 var nav = document.querySelector('nav.site-nav'); 1009 if (nav) { 1010 var rect = nav.getBoundingClientRect(); 1011 if (rect && rect.bottom > 0) {
1012 offset = Math.ceil(rect.bottom); 1013 } 1014 } 1015 } 1016 els.authModal.style.setProperty('--auth-modal-nav-offset', String(offset) + 'px'); 1017 } 1018 1019 function showPanel(panel, show) { 1020 if (!panel) { 1021 return; 1022 } 1023 panel.hidden = !show; 1024 } 1025 1026 function parseJsonResponse(text) { 1027 try { 1028 return JSON.parse(text); 1029 } catch (_) { 1030 var c = compact(text || ''); 1031 if (!c) { 1032 throw new Error('Invalid JSON response'); 1033 } 1034 throw new Error('Unexpected server response: ' + c.slice(0, 180)); 1035 } 1036 } 1037 1038 function fetchJson(url, options) { 1039 var request = Object.assign({ cache: 'no-store' }, options || {}); 1040 return fetch(url, request) 1041 .then(function (res) { 1042 return res.text().then(function (text) { 1043 return parseJsonResponse(text); 1044 }); 1045 }); 1046 } 1047 1048 function postForm(url, payload) { 1049 var body = new URLSearchParams(payload || {}); 1050 return fetchJson(url, { 1051 method: 'POST', 1052 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 1053 body: body.toString() 1054 }); 1055 } 1056 1057 function videoCallAuthPayload(payload) { 1058 var next = payload && typeof payload === 'object' ? Object.assign({}, payload) : {}; 1059 next.session_token = getSessionToken(); 1060 next.csrf_token = getCsrfToken(); 1061 return next; 1062 } 1063 1064 function siteNotificationCursorKey(kind) { 1065 var host = 'site'; 1066 try { 1067 host = String(window.location.hostname || 'site').toLowerCase(); 1068 } catch (_err) { 1069 host = 'site'; 1070 } 1071 return SITE_NOTIFICATION_CURSOR_PREFIX + host + ':' + String(kind || 'default'); 1072 } 1073 1074 function notificationTag(kind, id) { 1075 return String(kind || 'site') + ':' + String(id || ''); 1076 } 1077 1078 function ensureSiteNotificationHost() { 1079 if (state.siteNotificationHost && state.siteNotificationHost.parentNode) { 1080 return state.siteNotificationHost; 1081 } 1082 var host = document.getElementById('site-notification-host'); 1083 if (!host) { 1084 host = document.createElement('div'); 1085 host.id = 'site-notification-host'; 1086 host.setAttribute('aria-live', 'polite'); 1087 document.body.appendChild(host); 1088 } 1089 state.siteNotificationHost = host; 1090 return host; 1091 } 1092 1093 function ensureSiteNotificationStyles() { 1094 if (document.getElementById('site-notification-styles')) { 1095 return; 1096 } 1097 var style = document.createElement('style'); 1098 style.id = 'site-notification-styles'; 1099 style.textContent = '' 1100 + '#site-notification-host{position:fixed;right:16px;bottom:18px;z-index:2147482600;display:flex;flex-direction:column;gap:10px;max-width:min(23rem,calc(100vw - 32px));pointer-events:none;}' 1101 + '.site-notification-card{background:#fffaf1;color:#241b12;border:1px solid rgba(98,75,42,.28);box-shadow:0 16px 40px rgba(36,27,18,.2);border-radius:10px;padding:12px;font-family:Georgia,Times New Roman,serif;pointer-events:auto;}' 1102 + '.site-notification-card strong{display:block;font-size:1rem;line-height:1.2;margin:0 0 5px;}' 1103 + '.site-notification-card p{margin:0;color:#5c4b36;font-size:.92rem;line-height:1.35;}' 1104 + '.site-notification-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-top:9px;}' 1105 + '.site-notification-card button,.site-notification-card a{appearance:none;border:1px solid rgba(98,75,42,.36);background:#f5ead7;color:#241b12;border-radius:999px;padding:7px 12px;font:inherit;font-size:.92rem;line-height:1;text-decoration:none;cursor:pointer;}' 1106 + '.site-notification-card .primary{background:#2f63be;border-color:#2b56a4;color:white;}'; 1107 document.head.appendChild(style); 1108 } 1109 1110 function showBrowserNotification(title, body, tag, url) { 1111 if (!('Notification' in window) || window.Notification.permission !== 'granted') { 1112 return; 1113 } 1114 try { 1115 var note = new window.Notification(title, { 1116 body: body, 1117 tag: tag, 1118 renotify: false 1119 }); 1120 note.onclick = function () { 1121 try { 1122 window.focus(); 1123 } catch (_err) {} 1124 if (url) { 1125 window.location.href = url; 1126 } 1127 note.close(); 1128 }; 1129 } catch (_err) { 1130 // Browser notifications are optional; the in-page card is authoritative. 1131 } 1132 } 1133 1134 function showSiteNotification(options) { 1135 var opts = options && typeof options === 'object' ? options : {}; 1136 var id = notificationTag(opts.kind || 'site', opts.id || opts.title || Date.now()); 1137 if (state.seenSiteNotifications[id]) { 1138 return; 1139 } 1140 state.seenSiteNotifications[id] = true; 1141 ensureSiteNotificationStyles(); 1142 var host = ensureSiteNotificationHost(); 1143 var card = document.createElement('section'); 1144 card.className = 'site-notification-card'; 1145 card.setAttribute('role', opts.assertive ? 'alertdialog' : 'status'); 1146 card.setAttribute('aria-live', opts.assertive ? 'assertive' : 'polite'); 1147 var primaryUrl = String(opts.url || ''); 1148 card.innerHTML = '' 1149 + '<strong>' + escapeHtml(opts.title || 'Site notification') + '</strong>' 1150 + '<p>' + escapeHtml(opts.body || '') + '</p>' 1151 + '<div class="site-notification-actions">' 1152 + (primaryUrl ? '<a class="primary" href="' + escapeHtml(primaryUrl) + '">' + escapeHtml(opts.actionLabel || 'Open') + '</a>' : '') 1153 + '<button type="button" data-site-notification-dismiss="true">Dismiss</button>' 1154 + '</div>'; 1155 card.addEventListener('click', function (event) { 1156 var dismiss = event.target instanceof Element ? event.target.closest('[data-site-notification-dismiss]') : null; 1157 if (dismiss && card.parentNode) { 1158 card.parentNode.removeChild(card); 1159 } 1160 }); 1161 host.appendChild(card); 1162 window.setTimeout(function () { 1163 if (card.parentNode) { 1164 card.parentNode.removeChild(card); 1165 } 1166 }, opts.assertive ? 45000 : 18000); 1167 showBrowserNotification(opts.title || 'Site notification', opts.body || '', id, primaryUrl); 1168 } 1169 1170 function ensureVideoCallNotificationStyles() { 1171 if (document.getElementById('video-call-notification-styles')) { 1172 return; 1173 } 1174 var style = document.createElement('style'); 1175 style.id = 'video-call-notification-styles'; 1176 style.textContent = '' 1177 + '.video-call-notification{position:fixed;right:16px;bottom:18px;z-index:2147482600;max-width:min(22rem,calc(100vw - 32px));background:#fffaf1;color:#241b12;border:1px solid rgba(98,75,42,.28);box-shadow:0 16px 40px rgba(36,27,18,.2);border-radius:10px;padding:12px;display:flex;flex-direction:column;gap:9px;font-family:Georgia,Times New Roman,serif;}' 1178 + '.video-call-notification strong{font-size:1rem;line-height:1.2;}' 1179 + '.video-call-notification p{margin:0;color:#5c4b36;font-size:.92rem;line-height:1.35;}' 1180 + '.video-call-notification-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center;}' 1181 + '.video-call-notification button{appearance:none;border:1px solid rgba(98,75,42,.36);background:#f5ead7;color:#241b12;border-radius:999px;padding:7px 12px;font:inherit;font-size:.92rem;line-height:1;cursor:pointer;}' 1182 + '.video-call-notification button.primary{background:#2f63be;border-color:#2b56a4;color:white;}' 1183 + '.video-call-notification button:hover{filter:brightness(.98);}'; 1184 document.head.appendChild(style); 1185 } 1186 1187 function hideVideoCallNotification(callId) { 1188 if (callId && state.videoCallCurrentCallId && callId !== state.videoCallCurrentCallId) { 1189 return; 1190 } 1191 state.videoCallCurrentCallId = ''; 1192 if (state.videoCallNotification && state.videoCallNotification.parentNode) { 1193 state.videoCallNotification.parentNode.removeChild(state.videoCallNotification); 1194 } 1195 state.videoCallNotification = null; 1196 } 1197 1198 function answerVideoCall(callId) { 1199 return postForm('/cgi/blog-video-chat-control', videoCallAuthPayload({
1200 action: 'answer_call', 1201 call_id: callId 1202 })).then(function (data) { 1203 if (!data || !data.success) { 1204 throw new Error((data && data.error) || 'Could not answer call.'); 1205 } 1206 hideVideoCallNotification(callId); 1207 var roomId = String(data.room_id || (data.call && data.call.room_id) || '').trim(); 1208 var roomPassword = String(data.room_password || (data.call && data.call.room_password) || '').trim(); 1209 var callMode = String((data.call && data.call.call_mode) || 'video') === 'voice' ? 'voice' : 'video'; 1210 if (roomId) { 1211 var url = '/contact?room=' + encodeURIComponent(roomId) + '&auto_start=1&mode=' + encodeURIComponent(callMode); 1212 if (callId) { 1213 url += '&call_id=' + encodeURIComponent(callId); 1214 } 1215 if (roomPassword) { 1216 url += '&room_password=' + encodeURIComponent(roomPassword); 1217 } 1218 window.location.href = url; 1219 } 1220 }).catch(function () { 1221 hideVideoCallNotification(callId); 1222 }); 1223 } 1224 1225 function declineVideoCall(callId) { 1226 return postForm('/cgi/blog-video-chat-control', videoCallAuthPayload({ 1227 action: 'decline_call', 1228 call_id: callId 1229 })).catch(function () { 1230 return false; 1231 }).then(function () { 1232 hideVideoCallNotification(callId); 1233 }); 1234 } 1235 1236 function showVideoCallNotification(call) { 1237 if (!call || !call.call_id) { 1238 return; 1239 } 1240 var callId = String(call.call_id); 1241 if (state.videoCallCurrentCallId === callId && state.videoCallNotification) { 1242 return; 1243 } 1244 hideVideoCallNotification(); 1245 ensureVideoCallNotificationStyles(); 1246 var node = document.createElement('section'); 1247 node.className = 'video-call-notification'; 1248 node.setAttribute('role', 'alertdialog'); 1249 node.setAttribute('aria-live', 'assertive'); 1250 var isSelfTest = !!call.self_test; 1251 var isOwnerCall = !!call.owner_call; 1252 var callMode = String(call.call_mode || 'video') === 'voice' ? 'voice' : 'video'; 1253 var callKind = callMode === 'voice' ? 'voice call' : 'video call'; 1254 var title = isOwnerCall ? ('Private ' + callKind + ' for Anders') : (isSelfTest ? ('Self-test ' + callKind) : ('Incoming ' + callKind)); 1255 var body = isOwnerCall ? escapeHtml(call.from_admin_name || 'Website visitor') + ' is waiting in a private 1:1 room.' : (isSelfTest ? 'You started a test call to this signed-in account.' : escapeHtml(call.from_admin_name || call.from_admin || 'Site admin') + ' is calling you on this site.'); 1256 node.innerHTML = '' 1257 + '<strong>' + escapeHtml(title) + '</strong>' 1258 + '<p>' + body + '</p>' 1259 + '<div class="video-call-notification-actions">' 1260 + '<button type="button" class="primary" data-video-call-action="answer">Answer</button>' 1261 + '<button type="button" data-video-call-action="decline">Decline</button>' 1262 + '</div>'; 1263 node.addEventListener('click', function (event) { 1264 var button = event.target instanceof Element ? event.target.closest('[data-video-call-action]') : null; 1265 if (!button) { 1266 return; 1267 } 1268 var action = button.getAttribute('data-video-call-action'); 1269 if (action === 'answer') { 1270 answerVideoCall(callId); 1271 } else if (action === 'decline') { 1272 declineVideoCall(callId); 1273 } 1274 }); 1275 document.body.appendChild(node); 1276 state.videoCallNotification = node; 1277 state.videoCallCurrentCallId = callId; 1278 showBrowserNotification(title, String(node.querySelector('p') && node.querySelector('p').textContent || ''), notificationTag('video-call', callId), ''); 1279 } 1280 1281 function pollVideoCallPresence() { 1282 if (!state.isAuthenticated || !(state.plugins && state.plugins.video_chat) || !hasStoredSessionToken() || !getCsrfToken()) { 1283 hideVideoCallNotification(); 1284 return Promise.resolve(false); 1285 } 1286 var roomId = ''; 1287 try { 1288 roomId = String(window.__wizardryVideoChatRoomId || '').trim(); 1289 } catch (_err) { 1290 roomId = ''; 1291 } 1292 return postForm('/cgi/blog-video-chat-control', videoCallAuthPayload({ 1293 action: 'heartbeat', 1294 current_room: roomId, 1295 status: roomId ? 'in-room' : 'online', 1296 page_url: window.location.pathname + window.location.search 1297 })).then(function (data) { 1298 if (!data || !data.success) { 1299 return false;
1300 } 1301 state.videoCallAllowAdminCalls = !!data.allow_admin_calls; 1302 var calls = Array.isArray(data.incoming_calls) ? data.incoming_calls : []; 1303 if (calls.length) { 1304 showVideoCallNotification(calls[0]); 1305 } else { 1306 hideVideoCallNotification(); 1307 } 1308 return true; 1309 }).catch(function () { 1310 return false; 1311 }); 1312 } 1313 1314 function syncVideoCallPresencePolling() { 1315 if (state.videoCallPresenceTimer) { 1316 window.clearInterval(state.videoCallPresenceTimer); 1317 state.videoCallPresenceTimer = 0; 1318 } 1319 if (!state.isAuthenticated || !(state.plugins && state.plugins.video_chat) || !hasStoredSessionToken()) { 1320 hideVideoCallNotification(); 1321 return; 1322 } 1323 pollVideoCallPresence(); 1324 state.videoCallPresenceTimer = window.setInterval(function () { 1325 pollVideoCallPresence(); 1326 }, 12000); 1327 } 1328 1329 function pollSecureChatAdminNotifications() { 1330 if (!state.isAuthenticated || !state.isAdmin || !hasStoredSessionToken() || !getCsrfToken()) { 1331 return Promise.resolve(false); 1332 } 1333 var cursor = Number(state.secureChatNotificationCursor || localStorage.getItem(siteNotificationCursorKey('secure-chat')) || 0); 1334 if (!isFinite(cursor) || cursor < 0) { 1335 cursor = 0; 1336 } 1337 return postForm('/cgi/blog-secure-chat-admin', videoCallAuthPayload({ 1338 action: 'notification-status', 1339 since_seq: String(cursor) 1340 })).then(function (data) { 1341 if (!data || !data.success) { 1342 return false; 1343 } 1344 var nextCursor = Number(data.cursor_seq || cursor || 0); 1345 var messages = Array.isArray(data.messages) ? data.messages : []; 1346 if (cursor > 0) { 1347 messages.forEach(function (message) { 1348 var seq = Number(message && message.seq || 0); 1349 if (seq <= cursor) { 1350 return; 1351 } 1352 var text = compact(message && message.text || ''); 1353 showSiteNotification({ 1354 kind: 'secure-chat', 1355 id: message && (message.id || message.seq), 1356 title: 'Private message', 1357 body: (message && message.contact_name ? String(message.contact_name) + ': ' : '') + (text || 'New Secure Chat message'), 1358 url: '/admin#account', 1359 actionLabel: 'Open', 1360 assertive: false 1361 }); 1362 }); 1363 } 1364 if (nextCursor > cursor) { 1365 state.secureChatNotificationCursor = nextCursor; 1366 localStorage.setItem(siteNotificationCursorKey('secure-chat'), String(nextCursor)); 1367 } 1368 return true; 1369 }).catch(function () { 1370 return false; 1371 }); 1372 } 1373 1374 function syncSiteNotificationPolling() { 1375 if (state.siteNotificationTimer) { 1376 window.clearInterval(state.siteNotificationTimer); 1377 state.siteNotificationTimer = 0; 1378 } 1379 if (!state.isAuthenticated || !state.isAdmin || !hasStoredSessionToken()) { 1380 return; 1381 } 1382 pollSecureChatAdminNotifications(); 1383 state.siteNotificationTimer = window.setInterval(function () { 1384 pollSecureChatAdminNotifications(); 1385 }, 30000); 1386 } 1387 1388 function encodeBase64Utf8(text) { 1389 var raw = String(text || ''); 1390 try { 1391 return btoa(unescape(encodeURIComponent(raw))); 1392 } catch (_err) { 1393 return btoa(raw); 1394 } 1395 } 1396 1397 function getSessionToken() { 1398 return localStorage.getItem('session_token') || ''; 1399 } 1400 1401 function getCsrfToken() { 1402 return localStorage.getItem('csrf_token') || ''; 1403 } 1404 1405 function hasStoredSessionToken() { 1406 var token = String(getSessionToken() || '').trim(); 1407 return !!token && token !== 'null' && token !== 'undefined'; 1408 } 1409 1410 function emitAuthChanged(detail) { 1411 var extra = detail && typeof detail === 'object' ? detail : {}; 1412 try { 1413 window.dispatchEvent(new CustomEvent('blog-auth-changed', { 1414 detail: Object.assign({ 1415 session_token: getSessionToken(), 1416 csrf_token: getCsrfToken() 1417 }, extra) 1418 })); 1419 } catch (_err) { 1420 // Ignore event dispatch failures. 1421 } 1422 } 1423 1424 function rememberAuth(data) { 1425 localStorage.setItem('session_token', data.session_token || ''); 1426 localStorage.setItem('csrf_token', data.csrf_token || ''); 1427 localStorage.setItem('last_auth_method', 'nostr'); 1428 if (data.player_name) { 1429 localStorage.setItem('last_auth_player_name', data.player_name); 1430 } 1431 if (data.username) { 1432 localStorage.setItem('last_auth_username', data.username); 1433 } 1434 if (data.pubkey) { 1435 localStorage.setItem('last_auth_pubkey', data.pubkey); 1436 } 1437 } 1438 1439 function clearLocalStorageAuth() { 1440 localStorage.removeItem('session_token');
1441 localStorage.removeItem('csrf_token'); 1442 localStorage.removeItem('last_auth_method'); 1443 emitAuthChanged(); 1444 } 1445 1446 function resetSessionCheckGrace() { 1447 sessionCheckGraceToken = ''; 1448 sessionCheckGraceCount = 0; 1449 if (sessionCheckRetryTimer) { 1450 window.clearTimeout(sessionCheckRetryTimer); 1451 sessionCheckRetryTimer = 0; 1452 } 1453 } 1454 1455 function hasStoredNostrAuthHint() { 1456 var method = String(localStorage.getItem('last_auth_method') || '').trim().toLowerCase(); 1457 var pubkey = String(localStorage.getItem('last_auth_pubkey') || '').trim().toLowerCase(); 1458 return method === 'nostr' || !!pubkey; 1459 } 1460 1461 function scheduleSessionCheckRetry(token) { 1462 var retryToken = String(token || '').trim(); 1463 if (!retryToken) { 1464 return; 1465 } 1466 if (sessionCheckRetryTimer) { 1467 window.clearTimeout(sessionCheckRetryTimer); 1468 } 1469 sessionCheckRetryTimer = window.setTimeout(function () { 1470 sessionCheckRetryTimer = 0; 1471 var currentToken = String(getSessionToken() || '').trim(); 1472 if (!currentToken || currentToken !== retryToken) { 1473 return; 1474 } 1475 checkAuth(); 1476 }, 1200); 1477 } 1478 1479 function openAuthDb() { 1480 if (!window.indexedDB) { 1481 return Promise.resolve(null); 1482 } 1483 if (state.idbPromise) { 1484 return state.idbPromise; 1485 } 1486 state.idbPromise = new Promise(function (resolve, reject) { 1487 var req = window.indexedDB.open(IDB_DB_NAME, 1); 1488 req.onupgradeneeded = function () { 1489 var db = req.result; 1490 if (!db.objectStoreNames.contains(IDB_STORE_NAME)) { 1491 db.createObjectStore(IDB_STORE_NAME); 1492 } 1493 }; 1494 req.onsuccess = function () { 1495 resolve(req.result); 1496 }; 1497 req.onerror = function () { 1498 reject(req.error || new Error('IndexedDB unavailable')); 1499 }; 1500 }).catch(function () { 1501 return null; 1502 }); 1503 return state.idbPromise; 1504 } 1505 1506 function fallbackKey(key) { 1507 return 'wizardry_blog_auth_fallback_' + key; 1508 } 1509 1510 function idbSet(key, value) { 1511 return openAuthDb().then(function (db) { 1512 if (!db) { 1513 localStorage.setItem(fallbackKey(key), JSON.stringify(value)); 1514 return; 1515 } 1516 return new Promise(function (resolve, reject) { 1517 var tx = db.transaction(IDB_STORE_NAME, 'readwrite'); 1518 tx.objectStore(IDB_STORE_NAME).put(value, key); 1519 tx.oncomplete = function () { resolve(); }; 1520 tx.onerror = function () { reject(tx.error || new Error('IndexedDB write failed')); }; 1521 }); 1522 }); 1523 } 1524 1525 function idbGet(key) { 1526 return openAuthDb().then(function (db) { 1527 if (!db) { 1528 var raw = localStorage.getItem(fallbackKey(key)); 1529 if (!raw) { 1530 return null; 1531 } 1532 try { 1533 return JSON.parse(raw); 1534 } catch (_) { 1535 return null; 1536 } 1537 } 1538 return new Promise(function (resolve, reject) { 1539 var tx = db.transaction(IDB_STORE_NAME, 'readonly'); 1540 var req = tx.objectStore(IDB_STORE_NAME).get(key); 1541 req.onsuccess = function () { resolve(req.result || null); }; 1542 req.onerror = function () { reject(req.error || new Error('IndexedDB read failed')); }; 1543 }); 1544 }); 1545 } 1546 1547 function idbDelete(key) { 1548 return openAuthDb().then(function (db) { 1549 if (!db) { 1550 localStorage.removeItem(fallbackKey(key)); 1551 return; 1552 } 1553 return new Promise(function (resolve, reject) { 1554 var tx = db.transaction(IDB_STORE_NAME, 'readwrite'); 1555 tx.objectStore(IDB_STORE_NAME).delete(key); 1556 tx.oncomplete = function () { resolve(); }; 1557 tx.onerror = function () { reject(tx.error || new Error('IndexedDB delete failed')); }; 1558 }); 1559 }); 1560 } 1561 1562 function clearLocalKeyMaterial() { 1563 return Promise.all([ 1564 idbDelete(KEY_DEVICE_SESSION), 1565 idbDelete(KEY_NIP46_PAIR) 1566 ]).then(function () { 1567 state.nip46.active = false; 1568 state.nip46.signerPubkey = ''; 1569 state.nip46.accountPubkey = ''; 1570 state.nip46.appSecretHex = ''; 1571 state.nip46.appPubkey = ''; 1572 state.nip46.pairSecret = ''; 1573 state.nip46.pending = {}; 1574 state.nip46.pendingTimers = {}; 1575 state.nip46.seenEvents = {}; 1576 state.nip46.toolsWaitPromise = null; 1577 if (state.nip46.returnRefreshTimer) { 1578 clearTimeout(state.nip46.returnRefreshTimer); 1579 state.nip46.returnRefreshTimer = 0; 1580 }
1581 if (state.nip46.deepLinkFallbackTimer) { 1582 clearTimeout(state.nip46.deepLinkFallbackTimer); 1583 state.nip46.deepLinkFallbackTimer = 0; 1584 } 1585 state.nip46.lastReturnLoginAttemptAt = 0; 1586 state.nip46.autoLoginInFlight = false; 1587 state.nip46.diagnostics = { 1588 eventsSeen: 0, 1589 decryptErrors: 0, 1590 ignoredSecrets: 0, 1591 lastEventPubkey: '', 1592 lastMessage: '' 1593 }; 1594 if (state.nip46.subscription && typeof state.nip46.subscription.close === 'function') { 1595 state.nip46.subscription.close(); 1596 } 1597 state.nip46.subscription = null; 1598 if (state.nip46.pool && typeof state.nip46.pool.destroy === 'function') { 1599 state.nip46.pool.destroy(); 1600 } 1601 state.nip46.pool = null; 1602 }); 1603 } 1604 1605 function authEventTemplate(challenge, action, pubkey) { 1606 var eventAction = action || 'login'; 1607 var signerPubkey = String(pubkey || '').trim(); 1608 return window.citrine.nostr.createAuthEventTemplate({ 1609 kind: AUTH_KIND, 1610 challenge: challenge, 1611 action: eventAction, 1612 pubkey: signerPubkey, 1613 origin: currentOrigin(), 1614 domain: currentHost() 1615 }); 1616 } 1617 1618 function normalizeSignedEvent(result) { 1619 if (typeof result === 'string') { 1620 return parseJsonResponse(result); 1621 } 1622 if (result && typeof result === 'object') { 1623 return result; 1624 } 1625 throw new Error('Signer did not return a valid signed event.'); 1626 } 1627 1628 function normalizeSignedEventKind(value) { 1629 var num = Number(value); 1630 if (!Number.isFinite(num) || num <= 0) { 1631 return 0; 1632 } 1633 return Math.floor(num); 1634 } 1635 1636 function normalizeSignedEventForSubmit(result, pubkey, expectedKind) { 1637 var normalized = normalizeSignedEvent(result); 1638 var fallbackPubkey = normalizePubkeyHex(pubkey); 1639 var rawExistingPubkey = String((normalized && normalized.pubkey) || '').trim(); 1640 var existingPubkey = normalizePubkeyHex(rawExistingPubkey); 1641 var desiredKind = normalizeSignedEventKind(expectedKind); 1642 var existingKind = normalizeSignedEventKind(normalized && normalized.kind); 1643 var patched = normalized; 1644 if (normalized && fallbackPubkey && (!existingPubkey || rawExistingPubkey.toLowerCase() !== existingPubkey)) { 1645 patched = Object.assign({}, patched, { pubkey: fallbackPubkey }); 1646 } 1647 if (patched && desiredKind > 0 && existingKind !== desiredKind) { 1648 patched = Object.assign({}, patched, { kind: desiredKind }); 1649 } 1650 return patched; 1651 } 1652 1653 function signedEventPubkey(result) { 1654 var normalized = normalizeSignedEvent(result); 1655 return String((normalized && normalized.pubkey) || '').trim(); 1656 } 1657 1658 function normalizePubkeyHex(value) { 1659 return window.citrine.nostr.normalizeNostrPubkey(value, window.NostrTools); 1660 } 1661 1662 function beginChallenge(pubkeyHint) { 1663 var payload = {}; 1664 if (pubkeyHint) { 1665 payload.pubkey_hint = pubkeyHint; 1666 } 1667 return postForm('/cgi/nostr-auth-login-begin', payload) 1668 .then(function (data) { 1669 if (!data || !data.success) { 1670 throw new Error((data && data.error) || 'Unable to create login challenge.'); 1671 } 1672 return data; 1673 }); 1674 } 1675 1676 function finishLogin(requestId, signedEvent, delegationEvent, forceInteractive, usernameHint, signerPubkeyHint) { 1677 var payload = { 1678 request_id: requestId, 1679 event_json_b64: encodeBase64Utf8(JSON.stringify(normalizeSignedEventForSubmit(signedEvent, signerPubkeyHint, AUTH_KIND))), 1680 force_interactive: forceInteractive ? 'true' : 'false' 1681 }; 1682 var normalizedSignerPubkeyHint = normalizePubkeyHex(signerPubkeyHint); 1683 var desiredUsername = String(usernameHint || '').trim(); 1684 if (desiredUsername) { 1685 payload.username_hint = desiredUsername; 1686 } 1687 if (normalizedSignerPubkeyHint) { 1688 payload.signer_pubkey_hint = normalizedSignerPubkeyHint; 1689 } 1690 if (delegationEvent) { 1691 payload.delegation_json_b64 = encodeBase64Utf8(JSON.stringify(normalizeSignedEvent(delegationEvent))); 1692 } 1693 return postForm('/cgi/nostr-auth-login-finish', payload) 1694 .then(function (data) { 1695 if (!data || !data.success) { 1696 throw new Error((data && data.error) || 'Nostr login failed.'); 1697 } 1698 return data; 1699 }); 1700 } 1701 1702 function applyLoggedInUi(isLoggedIn, isAdmin, username) { 1703 var displayName = String(username || ''); 1704 state.isAuthenticated = !!isLoggedIn; 1705 state.isAdmin = !!isAdmin; 1706 1707 // Always reset both auth presentations first so they remain mutually exclusive. 1708 if (els.loginSplit) { 1709 els.loginSplit.style.display = 'none'; 1710 closeLoginMenu(); 1711 } else if (els.loginBtn) { 1712 els.loginBtn.style.display = 'none'; 1713 } 1714 if (els.userMenu) { 1715 els.userMenu.style.display = 'none';
1716 closeUserMenu(); 1717 } 1718 if (els.userName) { 1719 els.userName.style.display = 'none'; 1720 els.userName.textContent = ''; 1721 els.userName.removeAttribute('aria-label'); 1722 els.userName.setAttribute('href', '/admin#account'); 1723 els.userName.classList.remove('active'); 1724 els.userName.removeAttribute('aria-current'); 1725 } 1726 1727 if (isLoggedIn) { 1728 // Default to hidden until server session-check confirms there are 1729 // additional active sessions beyond the current one. 1730 updateLogoutOtherSessionsUi(0); 1731 if (els.composeTools) { 1732 els.composeTools.style.display = isAdmin ? 'inline-flex' : 'none'; 1733 } else if (els.composeLink) { 1734 els.composeLink.style.display = isAdmin ? 'inline-flex' : 'none'; 1735 } 1736 if (els.userMenu) { 1737 if (els.menuPrimaryLink) { 1738 if (isAdmin) { 1739 els.menuPrimaryLink.textContent = 'Admin'; 1740 els.menuPrimaryLink.href = '/admin'; 1741 } else { 1742 els.menuPrimaryLink.textContent = 'Account'; 1743 els.menuPrimaryLink.href = '/admin#account'; 1744 } 1745 } 1746 els.userMenu.style.display = 'inline-flex'; 1747 } 1748 if (els.userName) { 1749 els.userName.style.display = 'inline-block'; 1750 els.userName.textContent = displayName || 'signed-in'; 1751 els.userName.setAttribute('aria-label', 'Open account settings'); 1752 els.userName.setAttribute('href', '/admin#account'); 1753 updateUserNameActiveState(); 1754 } 1755 syncVideoCallPresencePolling(); 1756 syncSiteNotificationPolling(); 1757 return; 1758 } 1759 1760 if (els.loginSplit) { 1761 els.loginSplit.style.display = 'inline-flex'; 1762 } else if (els.loginBtn) { 1763 els.loginBtn.style.display = 'inline-block'; 1764 } 1765 if (els.composeTools) { 1766 els.composeTools.style.display = 'none'; 1767 } else if (els.composeLink) { 1768 els.composeLink.style.display = 'none'; 1769 } 1770 updateLogoutOtherSessionsUi(0); 1771 syncPluginAuthUi(); 1772 syncVideoCallPresencePolling(); 1773 syncSiteNotificationPolling(); 1774 } 1775 1776 function updateLogoutOtherSessionsUi(countRaw) { 1777 if (!els.menuLogoutEverywhereBtn) { 1778 return; 1779 } 1780 var count = Number(countRaw || 0); 1781 if (!isFinite(count) || count < 0) { 1782 count = 0; 1783 } 1784 if (count < 1) { 1785 els.menuLogoutEverywhereBtn.style.display = 'none'; 1786 els.menuLogoutEverywhereBtn.textContent = 'Log out other sessions'; 1787 if (els.menuLogoutMoreBtn) { 1788 els.menuLogoutMoreBtn.style.display = 'none'; 1789 els.menuLogoutMoreBtn.setAttribute('aria-expanded', 'false'); 1790 } 1791 closeLogoutSubmenu(); 1792 return; 1793 } 1794 els.menuLogoutEverywhereBtn.style.display = 'block'; 1795 els.menuLogoutEverywhereBtn.textContent = 'Log out other sessions (' + String(count) + ')'; 1796 if (els.menuLogoutMoreBtn) { 1797 els.menuLogoutMoreBtn.style.display = 'inline-flex'; 1798 } 1799 } 1800 1801 function checkAuth() { 1802 var token = String(getSessionToken() || '').trim(); 1803 if (!hasStoredSessionToken()) { 1804 resetSessionCheckGrace(); 1805 clearLocalStorageAuth(); 1806 applyLoggedInUi(false, false, ''); 1807 return Promise.resolve(false); 1808 } 1809 1810 return fetch('/cgi/ssh-auth-check-session?session_token=' + encodeURIComponent(token), { cache: 'no-store' }) 1811 .then(function (res) { return res.json(); }) 1812 .then(function (data) { 1813 if (!data || !data.authenticated) { 1814 if ((state.isAuthenticated || hasStoredNostrAuthHint()) && token) { 1815 if (sessionCheckGraceToken === token) { 1816 sessionCheckGraceCount += 1; 1817 } else { 1818 sessionCheckGraceToken = token; 1819 sessionCheckGraceCount = 1; 1820 } 1821 if (sessionCheckGraceCount < 2) { 1822 scheduleSessionCheckRetry(token); 1823 return !!state.isAuthenticated || hasStoredNostrAuthHint(); 1824 } 1825 } 1826 resetSessionCheckGrace(); 1827 clearLocalStorageAuth(); 1828 applyLoggedInUi(false, false, ''); 1829 return false; 1830 } 1831 resetSessionCheckGrace(); 1832 if (data.csrf_token) { 1833 localStorage.setItem('csrf_token', data.csrf_token); 1834 } 1835 if (data.nostr_pubkey) { 1836 localStorage.setItem('last_auth_pubkey', data.nostr_pubkey); 1837 } 1838 if (data.session_auth_method) { 1839 localStorage.setItem('last_auth_method', data.session_auth_method); 1840 } else if (data.nostr_pubkey) { 1841 localStorage.setItem('last_auth_method', 'nostr'); 1842 } else if (hasStoredNostrAuthHint()) { 1843 localStorage.setItem('last_auth_method', 'nostr'); 1844 } else { 1845 localStorage.removeItem('last_auth_method'); 1846 } 1847 if (data.player_name) { 1848 localStorage.setItem('last_auth_player_name', data.player_name); 1849 } 1850 if (typeof data.is_admin !== 'undefined') { 1851 localStorage.setItem('last_auth_is_admin', data.is_admin ? '1' : '0'); 1852 } 1853 applyLoggedInUi( 1854 true, 1855 !!data.is_admin, 1856 data.player_name || localStorage.getItem('last_auth_player_name') || data.username || '' 1857 ); 1858 updateLogoutOtherSessionsUi(data.other_sessions_count || 0); 1859 emitAuthChanged({ 1860 authenticated: true, 1861 is_admin: !!data.is_admin, 1862 username: data.username || '', 1863 player_name: data.player_name || localStorage.getItem('last_auth_player_name') || data.username || '', 1864 other_sessions_count: data.other_sessions_count || 0 1865 }); 1866 return true; 1867 }) 1868 .catch(function (err) { 1869 if (err && String(err.name || '') === 'AbortError') { 1870 return !!state.isAuthenticated; 1871 } 1872 if (!state.isAuthenticated && !hasStoredSessionToken()) { 1873 applyLoggedInUi(false, false, ''); 1874 } 1875 updateLogoutOtherSessionsUi(0); 1876 return !!state.isAuthenticated; 1877 }); 1878 } 1879 1880 function verifySessionWithRetry(remainingAttempts, delayMs) { 1881 var attempts = Number(remainingAttempts || 0); 1882 if (!isFinite(attempts) || attempts < 1) { 1883 attempts = 1; 1884 } 1885 return checkAuth().then(function (ok) { 1886 if (ok) { 1887 return true; 1888 } 1889 if (attempts <= 1) { 1890 return false;
1891 } 1892 return waitMs(delayMs).then(function () { 1893 return verifySessionWithRetry(attempts - 1, delayMs); 1894 }); 1895 }); 1896 } 1897 1898 function finalizeLoginUiAfterSuccess(finishData) { 1899 return verifySessionWithRetry(6, 180).then(function (ok) { 1900 if (!ok) { 1901 clearLocalStorageAuth(); 1902 applyLoggedInUi(false, false, ''); 1903 throw new Error('Login was signed, but session validation failed. Please try again.'); 1904 } 1905 hideAuthModal(); 1906 return true; 1907 }); 1908 } 1909 1910 function openUserMenu() { 1911 if (!els.menuPanel || !els.menuBtn) { 1912 return; 1913 } 1914 closeNavOverflowMenu(); 1915 els.menuPanel.hidden = false; 1916 els.menuBtn.setAttribute('aria-expanded', 'true'); 1917 } 1918 1919 function closeUserMenu() { 1920 if (!els.menuPanel || !els.menuBtn) { 1921 return; 1922 } 1923 closeLogoutSubmenu(); 1924 els.menuPanel.hidden = true; 1925 els.menuBtn.setAttribute('aria-expanded', 'false'); 1926 } 1927 1928 function openLogoutSubmenu() { 1929 if (!els.menuLogoutSubmenu || !els.menuLogoutMoreBtn) { 1930 return; 1931 } 1932 els.menuLogoutSubmenu.hidden = false; 1933 els.menuLogoutMoreBtn.setAttribute('aria-expanded', 'true'); 1934 } 1935 1936 function closeLogoutSubmenu() { 1937 if (!els.menuLogoutSubmenu || !els.menuLogoutMoreBtn) { 1938 return; 1939 } 1940 els.menuLogoutSubmenu.hidden = true; 1941 els.menuLogoutMoreBtn.setAttribute('aria-expanded', 'false'); 1942 } 1943 1944 function openLoginMenu() { 1945 if (!els.loginMenu || !els.loginMoreBtn) { 1946 return; 1947 } 1948 closeNavOverflowMenu(); 1949 els.loginMenu.hidden = false; 1950 els.loginMoreBtn.setAttribute('aria-expanded', 'true'); 1951 } 1952 1953 function closeLoginMenu() { 1954 if (!els.loginMenu || !els.loginMoreBtn) { 1955 return; 1956 } 1957 els.loginMenu.hidden = true; 1958 els.loginMoreBtn.setAttribute('aria-expanded', 'false'); 1959 } 1960 1961 function hasDesktopSigner() { 1962 return !!(window.nostr && typeof window.nostr.signEvent === 'function'); 1963 } 1964 1965 function waitForDesktopSigner(timeoutMs) { 1966 if (hasDesktopSigner()) { 1967 return Promise.resolve(true); 1968 } 1969 var startedAt = Date.now(); 1970 var timeout = Number(timeoutMs || 1200); 1971 if (!isFinite(timeout) || timeout < 0) { 1972 timeout = 1200; 1973 } 1974 return new Promise(function (resolve) { 1975 function check() { 1976 if (hasDesktopSigner()) { 1977 resolve(true); 1978 return; 1979 } 1980 if (Date.now() - startedAt >= timeout) { 1981 resolve(false); 1982 return; 1983 } 1984 window.setTimeout(check, 50); 1985 } 1986 check(); 1987 }); 1988 } 1989 1990 function isMobileLikeRuntime() { 1991 var nav = typeof navigator === 'object' ? navigator : null; 1992 var ua = String((nav && nav.userAgent) || ''); 1993 var uaData = nav && nav.userAgentData ? nav.userAgentData : null; 1994 if (uaData && uaData.mobile === true) { 1995 return true; 1996 } 1997 if (/Android|iPhone|iPad|iPod|Mobile/i.test(ua)) { 1998 return true; 1999 } 2000 var hasTouch = !!(nav && nav.maxTouchPoints && nav.maxTouchPoints > 0); 2001 var coarsePointer = false; 2002 var narrowViewport = false; 2003 if (typeof window.matchMedia === 'function') { 2004 coarsePointer = window.matchMedia('(pointer: coarse), (hover: none)').matches; 2005 narrowViewport = window.matchMedia('(max-width: 780px)').matches; 2006 } 2007 return hasTouch && (coarsePointer || narrowViewport); 2008 } 2009 2010 function detectedAuthPlatformFlavor() { 2011 var nav = typeof navigator === 'object' ? navigator : null; 2012 var ua = String((nav && nav.userAgent) || ''); 2013 var platform = String((nav && nav.platform) || ''); 2014 var uaData = nav && nav.userAgentData ? nav.userAgentData : null; 2015 var uaPlatform = String((uaData && uaData.platform) || ''); 2016 var combined = [ua, platform, uaPlatform].join(' '); 2017 var touchPoints = Number((nav && nav.maxTouchPoints) || 0); 2018 if (/Android/i.test(combined)) { 2019 return 'android'; 2020 } 2021 if (/iPhone|iPad|iPod/i.test(combined)) { 2022 return 'ios'; 2023 } 2024 // iPadOS can present itself as desktop Safari on MacIntel. 2025 if (/Mac/i.test(platform) && touchPoints > 1) { 2026 return 'ios'; 2027 } 2028 if (isMobileLikeRuntime()) { 2029 return 'remote'; 2030 } 2031 return 'desktop'; 2032 } 2033 2034 function preferredAuthInitialSelection() { 2035 var flavor = detectedAuthPlatformFlavor(); 2036 if (flavor === 'android' || flavor === 'ios' || flavor === 'remote') { 2037 return { tab: 'phone', flavor: flavor }; 2038 } 2039 return { tab: 'register', flavor: 'desktop' }; 2040 } 2041 2042 function normalizeAuthInitialSelection(initialTab, initialFlavor) { 2043 var fallback = preferredAuthInitialSelection(); 2044 if (initialTab && typeof initialTab === 'object') { 2045 return normalizeAuthInitialSelection(initialTab.tab || initialTab.route, initialTab.flavor); 2046 } 2047 var tab = String(initialTab || '').trim(); 2048 var flavor = String(initialFlavor || '').trim(); 2049 if (!tab || tab === 'auto') { 2050 return fallback; 2051 } 2052 if (tab === 'phone') { 2053 if (!flavor) { 2054 var detected = detectedAuthPlatformFlavor();
2055 flavor = (detected === 'android' || detected === 'ios') ? detected : 'remote'; 2056 } 2057 return { tab: 'phone', flavor: flavor }; 2058 } 2059 if (tab === 'manual') { 2060 return { tab: 'manual', flavor: 'manual' }; 2061 } 2062 return { tab: 'register', flavor: 'desktop' }; 2063 } 2064 2065 function preferredUnsignedLoginTab() { 2066 return preferredAuthInitialSelection(); 2067 } 2068 2069 function startPrimaryLogin() { 2070 var options = arguments[0]; 2071 var showFallbackModal = !(options && options.fallbackModal === false); 2072 closeLoginMenu(); 2073 return waitForDesktopSigner(1200).then(function (available) { 2074 if (!available) { 2075 if (showFallbackModal) { 2076 showAuthModal(preferredUnsignedLoginTab()); 2077 } else { 2078 showNavToast('No Nostr browser signer was found.', 'info', 4200); 2079 } 2080 return false; 2081 } 2082 return startDesktopSignerLogin(false, ''); 2083 }); 2084 } 2085 2086 function pageRequiresAuthorization() { 2087 var path = String(window.location.pathname || '').replace(/\/+$/, '') || '/'; 2088 if (path === '/pages/admin.html' || path === '/pages/admin' || path === '/admin.html' || path === '/admin') { 2089 return true; 2090 } 2091 if (document.body && document.body.getAttribute('data-requires-auth') === 'true') { 2092 return true; 2093 } 2094 return false; 2095 } 2096 2097 function handlePostLogoutNavigation(toastMessage) { 2098 var message = String(toastMessage || 'Logged out.'); 2099 if (pageRequiresAuthorization()) { 2100 rememberNavToast(message, 'info', 3800); 2101 window.location.assign('/'); 2102 return; 2103 } 2104 if (els.authModal && !els.authModal.hidden) { 2105 hideAuthModal(); 2106 } 2107 showNavToast(message, 'info', 3800); 2108 } 2109 2110 function logout() { 2111 var token = getSessionToken(); 2112 if (!token) { 2113 clearLocalStorageAuth(); 2114 return clearLocalKeyMaterial().finally(function () { 2115 applyLoggedInUi(false, false, ''); 2116 handlePostLogoutNavigation('Logged out.'); 2117 }); 2118 } 2119 2120 var body = new URLSearchParams({ 2121 session_token: token, 2122 csrf_token: getCsrfToken() 2123 }); 2124 2125 return fetch('/cgi/ssh-auth-logout', { 2126 method: 'POST', 2127 cache: 'no-store', 2128 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 2129 body: body.toString() 2130 }).catch(function () { 2131 return null; 2132 }).finally(function () { 2133 clearLocalStorageAuth(); 2134 return clearLocalKeyMaterial().finally(function () { 2135 applyLoggedInUi(false, false, ''); 2136 handlePostLogoutNavigation('Logged out.'); 2137 }); 2138 }); 2139 } 2140 2141 function buildNostrConnectUri(appPubkey, pairSecret, relays) { 2142 var metadata = { 2143 name: 'gazeta', 2144 url: window.location.origin, 2145 description: 'Sign in, vote on lists, and sign zaps for this blog.' 2146 }; 2147 return window.citrine.nostr.buildNostrConnectUri({ 2148 appPubkey: appPubkey, 2149 pairSecret: pairSecret, 2150 relays: relays, 2151 name: 'gazeta', 2152 metadata: metadata, 2153 perms: 'get_public_key,sign_event:22242,sign_event:9734,sign_event:7,sign_event:17,sign_event:5' 2154 }); 2155 } 2156 2157 function saveNip46PairState() { 2158 if (!state.nip46.appSecretHex || !state.nip46.appPubkey) { 2159 return Promise.resolve(); 2160 } 2161 return idbSet(KEY_NIP46_PAIR, { 2162 version: 2, 2163 domain: currentHost(), 2164 appSecretHex: state.nip46.appSecretHex, 2165 appPubkey: state.nip46.appPubkey, 2166 pairSecret: state.nip46.pairSecret, 2167 relays: state.nip46.relays, 2168 signerPubkey: normalizePubkeyHex(state.nip46.signerPubkey || ''), 2169 accountPubkey: normalizePubkeyHex(state.nip46.accountPubkey || ''), 2170 createdAt: nowEpoch() 2171 }); 2172 } 2173 2174 function renderQrCode(value) { 2175 if (!els.authNip46Qr) { 2176 return; 2177 } 2178 els.authNip46Qr.innerHTML = ''; 2179 if (typeof window.QRCode === 'function') { 2180 new window.QRCode(els.authNip46Qr, { 2181 text: value, 2182 width: 196, 2183 height: 196, 2184 colorDark: '#0f172a', 2185 colorLight: '#ffffff', 2186 correctLevel: window.QRCode.CorrectLevel.M 2187 }); 2188 return; 2189 } 2190 var pre = document.createElement('pre'); 2191 pre.textContent = value; 2192 els.authNip46Qr.appendChild(pre); 2193 } 2194 2195 function updateNip46PairingLink() { 2196 if (!state.nip46.appPubkey || !state.nip46.pairSecret) { 2197 return; 2198 } 2199 var uri = buildNostrConnectUri(state.nip46.appPubkey, state.nip46.pairSecret, state.nip46.relays); 2200 if (els.authNip46Uri) { 2201 els.authNip46Uri.textContent = uri; 2202 } 2203 if (els.authNip46Open) { 2204 els.authNip46Open.href = uri; 2205 els.authNip46Open.setAttribute('data-nip46-uri', uri); 2206 els.authNip46Open.textContent = 'Connect Nostr'; 2207 els.authNip46Open.setAttribute('aria-label', 'Connect Nostr with a signer app'); 2208 els.authNip46Open.setAttribute('title', 'Connect Nostr with a signer app'); 2209 } 2210 renderQrCode(uri); 2211 updatePhoneContinueState(); 2212 } 2213 2214 function currentNip46Uri() { 2215 if (!state.nip46.appPubkey || !state.nip46.pairSecret) { 2216 return ''; 2217 } 2218 return buildNostrConnectUri(state.nip46.appPubkey, state.nip46.pairSecret, state.nip46.relays); 2219 } 2220 2221 function hasPendingNip46Requests() { 2222 return Object.keys(state.nip46.pending || {}).length > 0; 2223 } 2224
2225 function scheduleDeepLinkFallbackHint() { 2226 if (state.nip46.deepLinkFallbackTimer) { 2227 clearTimeout(state.nip46.deepLinkFallbackTimer); 2228 state.nip46.deepLinkFallbackTimer = 0; 2229 } 2230 state.nip46.deepLinkFallbackTimer = window.setTimeout(function () { 2231 state.nip46.deepLinkFallbackTimer = 0; 2232 if (!isPhonePairingPanelActive() || document.hidden || state.nip46.signerPubkey) { 2233 return; 2234 } 2235 setNip46Diagnostics('No signer app response yet. The copy button gives the same Nostr Connect link.', 'warn'); 2236 setAuthMessage('No signer app response yet. Recommended Apps lists signer options, and the copy button gives a manual Nostr Connect link.', 'warn'); 2237 }, 1800); 2238 } 2239 2240 function refreshPhoneSignerListenerAfterReturn(reason) { 2241 if (!state.nip46.active || !state.nip46.appPubkey || !isPhonePairingPanelActive()) { 2242 return; 2243 } 2244 if (state.nip46.returnRefreshTimer) { 2245 clearTimeout(state.nip46.returnRefreshTimer); 2246 } 2247 state.nip46.returnRefreshTimer = window.setTimeout(function () { 2248 state.nip46.returnRefreshTimer = 0; 2249 waitForNostrTools(4000).then(function () { 2250 var status = 'Listening again for the signer response.'; 2251 if (hasPendingNip46Requests()) { 2252 status = 'Checking signer approval after return.'; 2253 } else if (state.nip46.signerPubkey) { 2254 status = 'Signer connected. Waiting for login approval.'; 2255 } 2256 ensureNip46Subscription(180, status); 2257 updateNip46PairingLink(); 2258 if (state.nip46.signerPubkey && !hasPendingNip46Requests() && !state.nip46.autoLoginInFlight) { 2259 var currentTime = Date.now(); 2260 if (!state.nip46.lastReturnLoginAttemptAt || currentTime - state.nip46.lastReturnLoginAttemptAt > 15000) { 2261 state.nip46.lastReturnLoginAttemptAt = currentTime; 2262 continuePhoneSignerLogin(true); 2263 } 2264 } 2265 }).catch(function (err) { 2266 setNip46Diagnostics((err && err.message) || 'Phone signer setup is still loading.', 'error'); 2267 }); 2268 }, reason === 'focus' ? 120 : 0); 2269 } 2270 2271 function openNativeDeepLink(uri, notReadyMessage) { 2272 var href = String(uri || '').trim(); 2273 if (!href || href === '#') { 2274 setAuthMessage(notReadyMessage || 'Phone signer link is not ready yet.', 'error'); 2275 return false; 2276 } 2277 try { 2278 setNip46Diagnostics('Opening signer app. Return here after approval.', 'info'); 2279 setAuthMessage('Android may switch to the signer app. After approving pairing, return here for the login approval.', 'plain'); 2280 scheduleDeepLinkFallbackHint(); 2281 window.location.href = href; 2282 return true; 2283 } catch (_err) { 2284 setAuthMessage('This browser did not open the phone signer link. Copy link is available as a fallback.', 'error'); 2285 return false; 2286 } 2287 } 2288 2289 function initNip46Pairing() { 2290 return waitForNostrTools(8500).then(function () { 2291 return initNip46PairingWithTools(); 2292 }); 2293 } 2294 2295 function initNip46PairingWithTools() { 2296 if (state.nip46.active) { 2297 updateNip46PairingLink(); 2298 ensureNip46Subscription(180, state.nip46.signerPubkey ? '' : 'Waiting for signer'); 2299 return Promise.resolve(); 2300 } 2301 2302 return idbGet(KEY_NIP46_PAIR).then(function (saved) { 2303 var appSecretHex = ''; 2304 var pairSecret = ''; 2305 var relays = NIP46_RELAYS.slice(); 2306 var savedRelays = []; 2307 var savedRelaysMatchDefaults = false; 2308 2309 if (saved && typeof saved === 'object' && saved.domain === currentHost()) { 2310 appSecretHex = String(saved.appSecretHex || ''); 2311 pairSecret = String(saved.pairSecret || ''); 2312 state.nip46.signerPubkey = normalizePubkeyHex(saved.signerPubkey || ''); 2313 state.nip46.accountPubkey = normalizePubkeyHex(saved.accountPubkey || ''); 2314 if (Array.isArray(saved.relays) && saved.relays.length) { 2315 savedRelays = saved.relays.map(function (item) { return String(item || '').trim(); }).filter(Boolean); 2316 savedRelaysMatchDefaults = savedRelays.length === NIP46_RELAYS.length && savedRelays.every(function (relay, idx) { 2317 return relay === NIP46_RELAYS[idx]; 2318 }); 2319 if (!savedRelaysMatchDefaults) { 2320 pairSecret = ''; 2321 } 2322 } 2323 } 2324 2325 if (!appSecretHex) { 2326 appSecretHex = bytesToHex(window.NostrTools.generateSecretKey()); 2327 } 2328 if (!pairSecret) { 2329 pairSecret = randomHex(16); 2330 } 2331 2332 var appPubkey = window.NostrTools.getPublicKey(hexToBytes(appSecretHex)); 2333 2334 state.nip46.active = true; 2335 state.nip46.appSecretHex = appSecretHex; 2336 state.nip46.appPubkey = appPubkey; 2337 state.nip46.pairSecret = pairSecret; 2338 state.nip46.relays = relays; 2339 state.nip46.signerPubkey = normalizePubkeyHex(state.nip46.signerPubkey || ''); 2340 state.nip46.accountPubkey = normalizePubkeyHex(state.nip46.accountPubkey || ''); 2341 state.nip46.pool = new window.NostrTools.SimplePool(); 2342 state.nip46.pending = {}; 2343 state.nip46.pendingTimers = {}; 2344 state.nip46.seenEvents = {}; 2345 state.nip46.autoLoginInFlight = false;
2346 state.nip46.diagnostics = { 2347 eventsSeen: 0, 2348 decryptErrors: 0, 2349 ignoredSecrets: 0, 2350 lastEventPubkey: '', 2351 lastMessage: '' 2352 }; 2353 2354 ensureNip46Subscription(30, 'Waiting for signer'); 2355 2356 return saveNip46PairState(); 2357 }).then(function () { 2358 updateNip46PairingLink(); 2359 }); 2360 } 2361 2362 function closeNip46Subscription() { 2363 if (state.nip46.subscription && typeof state.nip46.subscription.close === 'function') { 2364 state.nip46.subscription.close(); 2365 } 2366 state.nip46.subscription = null; 2367 } 2368 2369 function ensureNip46Pool() { 2370 if (!state.nip46.pool) { 2371 state.nip46.pool = new window.NostrTools.SimplePool(); 2372 } 2373 } 2374 2375 function ensureNip46Subscription(sinceSeconds, statusMessage) { 2376 if (!hasNostrTools() || !state.nip46.appPubkey) { 2377 return; 2378 } 2379 ensureNip46Pool(); 2380 closeNip46Subscription(); 2381 var lookback = Number(sinceSeconds || 30); 2382 if (!isFinite(lookback) || lookback < 30) { 2383 lookback = 30; 2384 } 2385 state.nip46.subscription = state.nip46.pool.subscribeMany( 2386 state.nip46.relays, 2387 [{ kinds: [NIP46_KIND], '#p': [state.nip46.appPubkey], since: nowEpoch() - lookback }], 2388 { 2389 onevent: function (event) { 2390 handleNip46RelayEvent(event); 2391 }, 2392 oneose: function () { 2393 if (!state.nip46.signerPubkey) { 2394 setNip46Diagnostics(statusMessage || 'Waiting for signer', 'waiting'); 2395 } 2396 }, 2397 onclose: function () { 2398 if (isPhonePairingPanelActive() && !state.nip46.signerPubkey && state.nip46.diagnostics.eventsSeen > 0) { 2399 setNip46Diagnostics('Signer listener paused after relay activity. Returning to this panel refreshes it automatically.', 'warn'); 2400 } 2401 } 2402 } 2403 ); 2404 if (statusMessage) { 2405 setNip46Diagnostics(statusMessage, statusMessage === 'Waiting for signer' ? 'waiting' : 'info'); 2406 } 2407 } 2408 2409 function resolveNip46Pending(id, payload, isError) { 2410 var entry = state.nip46.pending[id]; 2411 if (!entry) { 2412 return; 2413 } 2414 delete state.nip46.pending[id]; 2415 if (state.nip46.pendingTimers[id]) { 2416 clearTimeout(state.nip46.pendingTimers[id]); 2417 delete state.nip46.pendingTimers[id]; 2418 } 2419 if (isError) { 2420 entry.reject(new Error(payload || 'NIP-46 request failed')); 2421 } else { 2422 entry.resolve(payload); 2423 } 2424 } 2425 2426 function extractConnectSecret(msg) { 2427 return window.citrine.nostr.extractConnectSecret(msg); 2428 } 2429 2430 function decryptNip46Content(event) { 2431 if (window.NostrTools.nip44 && typeof window.NostrTools.nip44.getConversationKey === 'function') { 2432 try { 2433 var conversationKey = window.NostrTools.nip44.getConversationKey(hexToBytes(state.nip46.appSecretHex), event.pubkey); 2434 return Promise.resolve(window.NostrTools.nip44.decrypt(event.content, conversationKey)); 2435 } catch (_nip44Err) { 2436 // Older signers may still use the pre-NIP-44 Nostr Connect encryption path. 2437 } 2438 } 2439 return window.NostrTools.nip04.decrypt(hexToBytes(state.nip46.appSecretHex), event.pubkey, event.content); 2440 } 2441 2442 function encryptNip46Content(pubkey, plaintext) { 2443 if (window.NostrTools.nip44 && typeof window.NostrTools.nip44.getConversationKey === 'function') { 2444 var conversationKey = window.NostrTools.nip44.getConversationKey(hexToBytes(state.nip46.appSecretHex), pubkey); 2445 return Promise.resolve(window.NostrTools.nip44.encrypt(plaintext, conversationKey)); 2446 } 2447 return window.NostrTools.nip04.encrypt(hexToBytes(state.nip46.appSecretHex), pubkey, plaintext); 2448 } 2449 2450 function continuePhoneSignerLogin(autoStarted) { 2451 if (state.nip46.autoLoginInFlight) { 2452 return Promise.resolve(false); 2453 } 2454 state.nip46.autoLoginInFlight = true; 2455 if (autoStarted) { 2456 setAuthMessage('Phone signer paired. Approve the sign-in request in the signer.', 'ok'); 2457 } else { 2458 setAuthMessage('Starting phone signer login...', 'warn'); 2459 } 2460 setAuthControlsDisabled(true); 2461 var loginPromise = loginWithPhoneSigner(); 2462 if (!autoStarted && state.nip46.signerPubkey) { 2463 window.setTimeout(function () { 2464 openNativeDeepLink(currentNip46Uri(), 'Phone signer link is not ready yet.'); 2465 }, 80); 2466 } 2467 return loginPromise.catch(function (err) { 2468 if (els.authPhoneBtn) { 2469 els.authPhoneBtn.hidden = false;
2470 } 2471 setAuthMessage(err.message || 'Phone signer login failed.', 'error'); 2472 return false; 2473 }).finally(function () { 2474 state.nip46.autoLoginInFlight = false; 2475 setAuthControlsDisabled(false); 2476 updatePhoneContinueState(); 2477 }); 2478 } 2479 2480 function handleNip46RelayEvent(event) { 2481 if (!event || !event.id || state.nip46.seenEvents[event.id]) { 2482 return; 2483 } 2484 state.nip46.seenEvents[event.id] = true; 2485 state.nip46.diagnostics.eventsSeen += 1; 2486 state.nip46.diagnostics.lastEventPubkey = String(event.pubkey || ''); 2487 setNip46Diagnostics('Signer responded. Checking...', 'info'); 2488 2489 decryptNip46Content(event) 2490 .then(function (plain) { 2491 var msg = parseJsonResponse(plain); 2492 2493 if (msg && msg.method === 'connect') { 2494 var secret = extractConnectSecret(msg); 2495 if (secret && secret !== state.nip46.pairSecret) { 2496 state.nip46.diagnostics.ignoredSecrets += 1; 2497 setNip46Diagnostics('Saw an old signer response, but it belongs to a previous QR. Open the newest phone signer link.', 'warn'); 2498 return; 2499 } 2500 state.nip46.signerPubkey = normalizePubkeyHex(event.pubkey || ''); 2501 saveNip46PairState(); 2502 updatePhoneContinueState(); 2503 setNip46Diagnostics('Connected. Requesting approval.', 'ok'); 2504 continuePhoneSignerLogin(true); 2505 return; 2506 } 2507 2508 if (extractConnectSecret(msg) === state.nip46.pairSecret) { 2509 state.nip46.signerPubkey = normalizePubkeyHex(event.pubkey || ''); 2510 saveNip46PairState(); 2511 updatePhoneContinueState(); 2512 setNip46Diagnostics('Connected. Requesting approval.', 'ok'); 2513 continuePhoneSignerLogin(true); 2514 return; 2515 } 2516 2517 if (msg && msg.id && state.nip46.pending[msg.id]) { 2518 if (msg.error) { 2519 resolveNip46Pending(msg.id, typeof msg.error === 'string' ? msg.error : JSON.stringify(msg.error), true); 2520 return; 2521 } 2522 resolveNip46Pending(msg.id, msg.result, false); 2523 return; 2524 } 2525 setNip46Diagnostics('Saw a Nostr Connect event, but it was not the pairing response for this page.', 'warn'); 2526 }) 2527 .catch(function () { 2528 state.nip46.diagnostics.decryptErrors += 1; 2529 setNip46Diagnostics('Saw a signer response, but the page could not decrypt it. Open the newest phone signer link from this panel.', 'error'); 2530 }); 2531 } 2532 2533 function sendNip46Rpc(method, params, timeoutMs) { 2534 if (!state.nip46.signerPubkey) { 2535 return Promise.reject(new Error('Phone signer is not paired yet. Scan QR first.')); 2536 } 2537 2538 var requestId = randomHex(12); 2539 var timeout = Number(timeoutMs || 60000); 2540 var rpc = { 2541 id: requestId, 2542 method: method, 2543 params: params || [] 2544 }; 2545 2546 return encryptNip46Content(state.nip46.signerPubkey, JSON.stringify(rpc)).then(function (ciphertext) { 2547 var eventTemplate = { 2548 kind: NIP46_KIND, 2549 created_at: nowEpoch(), 2550 tags: [['p', state.nip46.signerPubkey]], 2551 content: ciphertext 2552 }; 2553 var signed = window.NostrTools.finalizeEvent(eventTemplate, hexToBytes(state.nip46.appSecretHex)); 2554 2555 return new Promise(function (resolve, reject) { 2556 state.nip46.pending[requestId] = { resolve: resolve, reject: reject }; 2557 state.nip46.pendingTimers[requestId] = setTimeout(function () { 2558 resolveNip46Pending(requestId, 'Phone signer timed out. Try again.', true); 2559 }, timeout); 2560 2561 state.nip46.pool.publish(state.nip46.relays, signed); 2562 }); 2563 }); 2564 } 2565 2566 function nip46SignEvent(template) { 2567 return sendNip46Rpc('sign_event', [JSON.stringify(template)], 70000) 2568 .then(function (result) { 2569 if (typeof result === 'string') { 2570 return parseJsonResponse(result); 2571 } 2572 return normalizeSignedEvent(result); 2573 }); 2574 } 2575 2576 function getNip46AccountPubkey() { 2577 var cached = normalizePubkeyHex(state.nip46.accountPubkey || ''); 2578 if (cached) { 2579 return Promise.resolve(cached); 2580 } 2581 return sendNip46Rpc('get_public_key', [], 30000).then(function (pubkey) { 2582 var normalized = normalizePubkeyHex(pubkey || ''); 2583 if (!normalized) { 2584 throw new Error('Phone signer did not return a valid account pubkey.'); 2585 } 2586 state.nip46.accountPubkey = normalized; 2587 return saveNip46PairState().then(function () { 2588 return normalized; 2589 }); 2590 }); 2591 } 2592 2593 function getNip46AccountPubkeyWithRetry(options) { 2594 var opts = options && typeof options === 'object' ? options : {}; 2595 var delays = [1200, 2400, 4800]; 2596 2597 if (opts.forceRefresh) { 2598 state.nip46.accountPubkey = ''; 2599 } 2600 2601 return window.citrine.nostr.withTimedOutRetry(getNip46AccountPubkey, { 2602 delays: delays, 2603 wait: function (delayMs) { 2604 return new Promise(function (resolve) { 2605 window.setTimeout(resolve, delayMs); 2606 }); 2607 }, 2608 onRetry: function () { 2609 setNip46Diagnostics('Signer connected. Waiting for approval response.', 'waiting'); 2610 } 2611 }); 2612 } 2613 2614 function signInWithSigner(signEventFn, options) { 2615 var opts = options && typeof options === 'object' ? options : {}; 2616 var getPubkeyFn = typeof opts.getPubkeyFn === 'function' ? opts.getPubkeyFn : null; 2617 var allowStoredPubkeyHint = opts.allowStoredPubkeyHint !== false; 2618 var pubkeyHint = normalizePubkeyHex(opts.pubkeyHint || '') || (allowStoredPubkeyHint ? normalizePubkeyHex(localStorage.getItem('last_auth_pubkey') || '') : ''); 2619 var registerAttempt = !!opts.registerAttempt; 2620 var usernameHint = String(opts.usernameHint || '').trim(); 2621 setAuthMessage('Creating a single-use login challenge...', 'warn'); 2622 var pubkeyReady = pubkeyHint 2623 ? Promise.resolve(pubkeyHint) 2624 : (getPubkeyFn 2625 ? Promise.resolve(getPubkeyFn()).then(function (value) { 2626 return normalizePubkeyHex(value); 2627 }).catch(function () { 2628 return ''; 2629 }) 2630 : Promise.resolve('')); 2631 return pubkeyReady 2632 .then(function (resolvedPubkeyHint) { 2633 pubkeyHint = resolvedPubkeyHint || pubkeyHint; 2634 return beginChallenge(pubkeyHint || ''); 2635 }) 2636 .then(function (begin) { 2637 var authTemplate = authEventTemplate(begin.challenge, 'login', pubkeyHint); 2638 setAuthMessage('Sign the login challenge event...', 'warn'); 2639 return requestSignerApproval( 2640 signEventFn, 2641 authTemplate, 2642 'Approve login in your signer', 2643 70000 2644 ).then(function (signedAuth) { 2645 var normalizedAuth = normalizeSignedEvent(signedAuth); 2646 var userPubkey = normalizePubkeyHex(signedEventPubkey(normalizedAuth)); 2647 if (!userPubkey && pubkeyHint) { 2648 userPubkey = normalizePubkeyHex(pubkeyHint); 2649 } 2650 if (!userPubkey && getPubkeyFn) { 2651 return Promise.resolve(getPubkeyFn()).then(function (fallbackPubkey) { 2652 var fallback = normalizePubkeyHex(fallbackPubkey); 2653 return { 2654 begin: begin, 2655 userPubkey: fallback, 2656 signedAuth: normalizeSignedEventForSubmit(normalizedAuth, fallback, AUTH_KIND) 2657 }; 2658 }); 2659 } 2660 normalizedAuth = normalizeSignedEventForSubmit(normalizedAuth, userPubkey, AUTH_KIND); 2661 return { 2662 begin: begin, 2663 userPubkey: userPubkey, 2664 signedAuth: normalizedAuth 2665 }; 2666 }); 2667 }) 2668 .then(function (payload) { 2669 if (!payload.userPubkey) { 2670 throw new Error('Signed auth event is missing pubkey.'); 2671 } 2672 localStorage.setItem('last_auth_pubkey', payload.userPubkey); 2673 return finishLogin( 2674 payload.begin.request_id, 2675 payload.signedAuth, 2676 null, 2677 false, 2678 usernameHint, 2679 payload.userPubkey 2680 ); 2681 }) 2682 .then(function (finish) { 2683 rememberAuth(finish); 2684 return idbDelete(KEY_DEVICE_SESSION).then(function () { 2685 var created = !!(finish && (finish.account_created === true || finish.account_created === 'true')); 2686 if (registerAttempt && !created) { 2687 showNavToast('You were logged in because this account already exists.', 'ok', 4200); 2688 } 2689 return finalizeLoginUiAfterSuccess(finish); 2690 }); 2691 }); 2692 } 2693 2694 function loginWithNip07(options) { 2695 var signer = getBrowserSigner(); 2696 var opts = options && typeof options === 'object' ? options : {}; 2697 try { 2698 if (typeof window.focus === 'function') { 2699 window.focus(); 2700 } 2701 } catch (_focusErr) { 2702 // noop 2703 } 2704 return signInWithSigner( 2705 function (template) { 2706 return Promise.resolve(signer.signEvent(template)); 2707 }, 2708 { 2709 getPubkeyFn: typeof signer.getPublicKey === 'function' 2710 ? function () { return Promise.resolve(signer.getPublicKey()); } 2711 : null, 2712 pubkeyHint: '', 2713 registerAttempt: !!opts.registerAttempt, 2714 usernameHint: String(opts.usernameHint || '').trim() 2715 } 2716 ); 2717 } 2718 2719 function resetPhonePairingLink(options) { 2720 var opts = options && typeof options === 'object' ? options : {}; 2721 var rotateAppKey = opts.rotateAppKey !== false; 2722 return waitForNostrTools(8500).then(function () { 2723 return idbGet(KEY_NIP46_PAIR); 2724 }).then(function (saved) { 2725 var nextSecret = ''; 2726 if (!rotateAppKey) { 2727 nextSecret = saved && typeof saved === 'object' ? String(saved.appSecretHex || '') : ''; 2728 if (!nextSecret) { 2729 nextSecret = state.nip46.appSecretHex || ''; 2730 } 2731 } 2732 if (!nextSecret) { 2733 nextSecret = bytesToHex(window.NostrTools.generateSecretKey()); 2734 }
2735 return idbSet(KEY_NIP46_PAIR, { 2736 version: 2, 2737 domain: currentHost(), 2738 appSecretHex: nextSecret, 2739 appPubkey: window.NostrTools.getPublicKey(hexToBytes(nextSecret)), 2740 pairSecret: randomHex(16), 2741 relays: NIP46_RELAYS.slice(), 2742 signerPubkey: '', 2743 accountPubkey: '', 2744 createdAt: nowEpoch() 2745 }); 2746 }).then(function () { 2747 if (state.nip46.subscription && typeof state.nip46.subscription.close === 'function') { 2748 state.nip46.subscription.close(); 2749 } 2750 state.nip46.subscription = null; 2751 if (state.nip46.pool && typeof state.nip46.pool.destroy === 'function') { 2752 state.nip46.pool.destroy(); 2753 } 2754 state.nip46.pool = null; 2755 state.nip46.active = false; 2756 state.nip46.signerPubkey = ''; 2757 state.nip46.accountPubkey = ''; 2758 state.nip46.autoLoginInFlight = false; 2759 return initNip46Pairing(); 2760 }); 2761 } 2762 2763 function rotateUnpairedNip46StateNow() { 2764 if (!hasNostrTools() || state.nip46.signerPubkey) { 2765 return false; 2766 } 2767 closeNip46Subscription(); 2768 if (state.nip46.pool && typeof state.nip46.pool.destroy === 'function') { 2769 state.nip46.pool.destroy(); 2770 } 2771 var appSecretHex = bytesToHex(window.NostrTools.generateSecretKey()); 2772 state.nip46.active = true; 2773 state.nip46.appSecretHex = appSecretHex; 2774 state.nip46.appPubkey = window.NostrTools.getPublicKey(hexToBytes(appSecretHex)); 2775 state.nip46.pairSecret = randomHex(16); 2776 state.nip46.relays = NIP46_RELAYS.slice(); 2777 state.nip46.signerPubkey = ''; 2778 state.nip46.accountPubkey = ''; 2779 state.nip46.pool = new window.NostrTools.SimplePool(); 2780 state.nip46.pending = {}; 2781 state.nip46.pendingTimers = {}; 2782 state.nip46.seenEvents = {}; 2783 state.nip46.autoLoginInFlight = false; 2784 state.nip46.diagnostics = { 2785 eventsSeen: 0, 2786 decryptErrors: 0, 2787 ignoredSecrets: 0, 2788 lastEventPubkey: '', 2789 lastMessage: '' 2790 }; 2791 ensureNip46Subscription(30, 'Waiting for signer'); 2792 updateNip46PairingLink(); 2793 saveNip46PairState().catch(function () { 2794 // The in-memory fresh link remains valid even if persistence is unavailable. 2795 }); 2796 return true; 2797 } 2798 2799 function refreshUnpairedNip46Link() { 2800 if (state.nip46.signerPubkey) { 2801 updateNip46PairingLink(); 2802 return Promise.resolve(); 2803 } 2804 setNip46Diagnostics('Making a fresh signer link.', 'info'); 2805 return resetPhonePairingLink({ rotateAppKey: true }); 2806 } 2807 2808 function startDesktopSignerLogin(registerAttempt, usernameHint) { 2809 var asRegister = !!registerAttempt; 2810 if (!hasDesktopSigner()) { 2811 return Promise.reject(new Error('No browser signer detected. Use phone signer QR or signed challenge login.')); 2812 } 2813 setAuthControlsDisabled(true); 2814 return loginWithNip07({ 2815 registerAttempt: asRegister, 2816 usernameHint: String(usernameHint || '').trim() 2817 }).finally(function () { 2818 setAuthControlsDisabled(false); 2819 }); 2820 } 2821 2822 function loginWithPhoneSigner() { 2823 return waitForNostrTools(8500) 2824 .then(function () { 2825 showPanel(els.authPhonePanel, true); 2826 showPanel(els.authManualPanel, false); 2827 2828 return initNip46Pairing() 2829 .then(function () { 2830 if (!state.nip46.signerPubkey) { 2831 throw new Error('Phone signer is not paired yet. Open the signer link or scan the QR first.'); 2832 } 2833 return signInWithSigner( 2834 function (template) { 2835 return nip46SignEvent(template); 2836 }, 2837 { 2838 getPubkeyFn: function () { 2839 return getNip46AccountPubkeyWithRetry({ forceRefresh: true }); 2840 }, 2841 pubkeyHint: '', 2842 allowStoredPubkeyHint: false 2843 } 2844 ); 2845 }); 2846 }); 2847 } 2848 2849 function prepareManualLogin() { 2850 setAuthMessage('Creating a single-use login challenge...', 'warn'); 2851 showPanel(els.authManualPanel, true); 2852 showPanel(els.authPhonePanel, false); 2853 2854 return beginChallenge(localStorage.getItem('last_auth_pubkey') || '') 2855 .then(function (begin) { 2856 state.manualChallenge = begin; 2857 2858 if (els.authManualRequestId) { 2859 els.authManualRequestId.value = begin.request_id || ''; 2860 } 2861 if (els.authManualChallenge) { 2862 els.authManualChallenge.value = begin.challenge || ''; 2863 } 2864 if (els.authManualExpires) {
2865 els.authManualExpires.value = String(begin.expires_at || ''); 2866 } 2867 2868 var authTemplate = authEventTemplate(begin.challenge, 'login', localStorage.getItem('last_auth_pubkey') || ''); 2869 if (els.authManualTemplate) { 2870 els.authManualTemplate.value = JSON.stringify(authTemplate, null, 2); 2871 } 2872 2873 setAuthMessage('Challenge created. Sign the auth event and paste JSON below.', 'ok'); 2874 }); 2875 } 2876 2877 function submitManualLogin() { 2878 if (!state.manualChallenge || !state.manualChallenge.request_id) { 2879 return Promise.reject(new Error('Create a challenge first.')); 2880 } 2881 2882 var signedAuthRaw = els.authManualEvent ? String(els.authManualEvent.value || '').trim() : ''; 2883 if (!signedAuthRaw) { 2884 return Promise.reject(new Error('Signed auth event JSON is required.')); 2885 } 2886 2887 var signedAuth; 2888 try { 2889 signedAuth = parseJsonResponse(signedAuthRaw); 2890 } catch (_) { 2891 throw new Error('Signed auth event JSON is invalid.'); 2892 } 2893 2894 return finishLogin( 2895 state.manualChallenge.request_id, 2896 signedAuth, 2897 null, 2898 false 2899 ).then(function (finish) { 2900 rememberAuth(finish); 2901 return idbDelete(KEY_DEVICE_SESSION).then(function () { 2902 return finalizeLoginUiAfterSuccess(finish); 2903 }); 2904 }); 2905 } 2906 2907 function revokeEverywhereWithSigner(signEventFn) { 2908 var token = getSessionToken(); 2909 var csrf = getCsrfToken(); 2910 if (!token || !csrf) { 2911 return Promise.reject(new Error('You are not currently signed in.')); 2912 } 2913 2914 return postForm('/cgi/nostr-auth-revoke-all-begin', { 2915 session_token: token, 2916 csrf_token: csrf 2917 }).then(function (begin) { 2918 if (!begin || !begin.success) { 2919 throw new Error((begin && begin.error) || 'Unable to start revocation challenge.'); 2920 } 2921 2922 var revokeTemplate = authEventTemplate(begin.challenge, 'revoke_all', begin.pubkey || ''); 2923 return Promise.resolve(signEventFn(revokeTemplate)) 2924 .then(function (signed) { 2925 return postForm('/cgi/nostr-auth-revoke-all-finish', { 2926 session_token: token, 2927 csrf_token: csrf, 2928 request_id: begin.request_id, 2929 event_json_b64: encodeBase64Utf8(JSON.stringify(normalizeSignedEvent(signed))) 2930 }); 2931 }); 2932 }).then(function (finish) { 2933 if (!finish || !finish.success) { 2934 throw new Error((finish && finish.error) || 'Revocation failed.'); 2935 } 2936 setAuthMessage('All active delegated sessions were revoked.', 'ok'); 2937 clearLocalStorageAuth(); 2938 return clearLocalKeyMaterial().finally(function () { 2939 applyLoggedInUi(false, false, ''); 2940 handlePostLogoutNavigation('Logged out other sessions.'); 2941 }); 2942 }); 2943 } 2944 2945 function logoutEverywhere() { 2946 if (typeof window.nostr !== 'undefined' && window.nostr && typeof window.nostr.signEvent === 'function') { 2947 return revokeEverywhereWithSigner(function (template) { 2948 return Promise.resolve(window.nostr.signEvent(template)); 2949 }); 2950 } 2951 2952 if (state.nip46.signerPubkey) { 2953 return revokeEverywhereWithSigner(function (template) { 2954 return nip46SignEvent(template); 2955 }); 2956 } 2957 2958 return Promise.reject(new Error('Fresh signer approval is required. Use Login or the phone signer flow first.')); 2959 } 2960 2961 function sharedSignerUnavailableMessage() { 2962 return 'No Nostr signer detected. Use a browser signer or pair a phone signer from Sign In.'; 2963 } 2964 2965 function resolveSharedNostrSigner(opts) { 2966 var options = opts && typeof opts === 'object' ? opts : {}; 2967 return window.citrine.nostr.createSharedNostrSigner({ 2968 target: window, 2969 unavailableMessage: sharedSignerUnavailableMessage(), 2970 getLastPubkey: function () { 2971 return localStorage.getItem('last_auth_pubkey') || ''; 2972 }, 2973 nip46Client: { 2974 state: function () { 2975 return state.nip46; 2976 }, 2977 ensurePairing: initNip46Pairing, 2978 sendRpc: sendNip46Rpc, 2979 getAccountPubkey: getNip46AccountPubkey 2980 } 2981 }).resolve(options); 2982 } 2983 2984 function createBlogNostrSigner() { 2985 return window.citrine.nostr.createSharedNostrSigner({ 2986 target: window, 2987 unavailableMessage: sharedSignerUnavailableMessage(), 2988 getLastPubkey: function () { 2989 return localStorage.getItem('last_auth_pubkey') || ''; 2990 }, 2991 nip46Client: { 2992 state: function () { 2993 return state.nip46; 2994 }, 2995 ensurePairing: initNip46Pairing, 2996 sendRpc: sendNip46Rpc, 2997 getAccountPubkey: getNip46AccountPubkey 2998 } 2999 }); 3000 } 3001 3002 function exposeSharedNostrSigner() { 3003 if (window.blogNostrSigner && window.blogNostrSigner.__wizardryShared === true) { 3004 return; 3005 } 3006 window.blogNostrSigner = createBlogNostrSigner(); 3007 } 3008 3009 exposeSharedNostrSigner(); 3010 3011 function isAccountAreaPath() { 3012 var path = String(window.location.pathname || '').replace(/\/+$/, '') || '/'; 3013 return path === '/pages/admin.html' || 3014 path === '/pages/admin' || 3015 path === '/admin.html' || 3016 path === '/admin'; 3017 } 3018 3019 function updateUserNameActiveState() { 3020 if (!els.userName) { 3021 return; 3022 } 3023 var active = isAccountAreaPath(); 3024 els.userName.classList.toggle('active', active); 3025 if (active) { 3026 els.userName.setAttribute('aria-current', 'page'); 3027 } else { 3028 els.userName.removeAttribute('aria-current'); 3029 } 3030 } 3031 3032 function escapeHtml(text) { 3033 return String(text || '') 3034 .replace(/&/g, '&') 3035 .replace(/</g, '<') 3036 .replace(/>/g, '>') 3037 .replace(/"/g, '"') 3038 .replace(/'/g, '''); 3039 } 3040 3041 function closeNavOverflowMenu() { 3042 if (els.navOverflowPanel) { 3043 els.navOverflowPanel.hidden = true; 3044 els.navOverflowPanel.classList.remove('is-viewport-positioned'); 3045 els.navOverflowPanel.style.left = ''; 3046 els.navOverflowPanel.style.top = ''; 3047 els.navOverflowPanel.style.maxWidth = ''; 3048 } 3049 if (els.navOverflowBtn) { 3050 els.navOverflowBtn.setAttribute('aria-expanded', 'false'); 3051 } 3052 } 3053 3054 function clampNavOverflowPanelToViewport() { 3055 if (!els.navOverflowPanel || !els.navOverflowBtn) { 3056 return; 3057 } 3058 var gap = 8; 3059 var triggerRect = els.navOverflowBtn.getBoundingClientRect(); 3060 var panelRect = els.navOverflowPanel.getBoundingClientRect(); 3061 var viewportWidth = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0); 3062 var panelWidth = Math.min(panelRect.width || 176, Math.max(160, viewportWidth - (gap * 2))); 3063 var left = triggerRect.left; 3064 var maxLeft = viewportWidth - panelWidth - gap; 3065 if (left > maxLeft) { 3066 left = maxLeft; 3067 } 3068 if (left < gap) { 3069 left = gap; 3070 } 3071 els.navOverflowPanel.classList.add('is-viewport-positioned'); 3072 els.navOverflowPanel.style.maxWidth = String(panelWidth) + 'px'; 3073 els.navOverflowPanel.style.left = String(Math.round(left)) + 'px'; 3074 els.navOverflowPanel.style.top = String(Math.round(triggerRect.bottom + 6)) + 'px'; 3075 } 3076 3077 function openNavOverflowMenu() { 3078 if (!els.navOverflowPanel || !els.navOverflowBtn || !els.navOverflowMenu || els.navOverflowMenu.hidden) { 3079 return; 3080 } 3081 els.navOverflowPanel.hidden = false;
3082 clampNavOverflowPanelToViewport(); 3083 els.navOverflowBtn.setAttribute('aria-expanded', 'true'); 3084 } 3085 3086 function setNavOverflowButtonCount(hiddenCount) { 3087 if (!els.navOverflowBtn) { 3088 return; 3089 } 3090 var count = Number(hiddenCount || 0); 3091 if (!isFinite(count) || count < 0) { 3092 count = 0; 3093 } 3094 if (els.navOverflowCount) { 3095 if (count > 0) { 3096 els.navOverflowCount.textContent = String(count); 3097 els.navOverflowCount.hidden = false; 3098 } else { 3099 els.navOverflowCount.textContent = '0'; 3100 els.navOverflowCount.hidden = true; 3101 } 3102 } 3103 if (count > 0) { 3104 els.navOverflowBtn.setAttribute('aria-label', 'More pages (' + String(count) + ' hidden)'); 3105 } else { 3106 els.navOverflowBtn.setAttribute('aria-label', 'More pages'); 3107 } 3108 } 3109 3110 function markNavOverflowReady() { 3111 if (state.navOverflowReady) { 3112 return; 3113 } 3114 state.navOverflowReady = true; 3115 markHydrationNavReady(); 3116 } 3117 3118 function syncNavOverflowMenuNow() { 3119 if (state.navOverflowReady) { 3120 return; 3121 }
3122 var navCenter = document.querySelector('.nav-center'); 3123 var navRight = document.querySelector('.nav-right'); 3124 if (!navCenter || !navRight || !els.navOverflowMenu || !els.navOverflowBtn || !els.navOverflowPanel) { 3125 markNavOverflowReady(); 3126 return; 3127 } 3128 3129 var links = Array.prototype.slice.call(navCenter.querySelectorAll('a[data-page]')); 3130 closeNavOverflowMenu(); 3131 els.navOverflowPanel.innerHTML = ''; 3132 var useMobileOverflowTier = !!(window.matchMedia && window.matchMedia('(max-width: 780px)').matches); 3133 if (useMobileOverflowTier) { 3134 links.forEach(function (link) { 3135 var rank = Number(link.getAttribute('data-nav-rank') || 0); 3136 if (rank >= 6) { 3137 link.classList.add('is-nav-overflow-hidden'); 3138 } 3139 }); 3140 } 3141 if (links.length < 2) { 3142 links.forEach(function (link) { 3143 link.classList.remove('is-nav-overflow-hidden'); 3144 }); 3145 els.navOverflowMenu.hidden = true; 3146 setNavOverflowButtonCount(0); 3147 markNavOverflowReady(); 3148 return; 3149 } 3150 3151 function rectVisible(rect) { 3152 return !!rect && rect.width > 0 && rect.height > 0; 3153 } 3154 3155 function rectsVerticallyOverlap(a, b) { 3156 return !!(a && b && a.bottom > b.top && b.bottom > a.top); 3157 } 3158 3159 function rightmostVisibleLinkRect() { 3160 for (var idx = links.length - 1; idx >= 0; idx -= 1) { 3161 var link = links[idx]; 3162 if (link.classList.contains('is-nav-overflow-hidden')) { 3163 continue; 3164 } 3165 var rect = link.getBoundingClientRect(); 3166 if (rectVisible(rect)) { 3167 return rect; 3168 } 3169 } 3170 return null; 3171 } 3172 3173 function navCollisionLeft() { 3174 var search = navRight.querySelector('.nav-search'); 3175 var target = search && !search.hidden ? search : navRight; 3176 var rect = target.getBoundingClientRect(); 3177 if (!rectVisible(rect)) { 3178 return null; 3179 } 3180 return rect.left; 3181 } 3182 3183 function hasRightSideCollision() { 3184 var linkRect = rightmostVisibleLinkRect(); 3185 var collisionLeft = navCollisionLeft(); 3186 if (!linkRect || collisionLeft === null) { 3187 return false; 3188 } 3189 var centerRect = navCenter.getBoundingClientRect(); 3190 var rightRect = navRight.getBoundingClientRect(); 3191 if (!rectsVerticallyOverlap(centerRect, rightRect)) { 3192 return false; 3193 } 3194 var gutter = 8; 3195 return linkRect.right > (collisionLeft - gutter); 3196 } 3197 3198 function hasNavPressure() { 3199 var widthPressure = navCenter.scrollWidth > (navCenter.clientWidth + 1); 3200 var overlapPressure = hasRightSideCollision(); 3201 return widthPressure || overlapPressure; 3202 } 3203 3204 var activeLink = links.find(function (link) { return link.classList.contains('active'); }) || null; 3205 var guard = 0; 3206 3207 while (hasNavPressure() && guard < links.length) { 3208 var hideCandidate = null; 3209 for (var i = links.length - 1; i >= 0; i -= 1) { 3210 var visibleLink = links[i]; 3211 if (visibleLink.classList.contains('is-nav-overflow-hidden')) { 3212 continue; 3213 } 3214 if (activeLink && visibleLink === activeLink) { 3215 continue; 3216 } 3217 hideCandidate = visibleLink; 3218 break; 3219 } 3220 if (!hideCandidate && activeLink && !activeLink.classList.contains('is-nav-overflow-hidden')) { 3221 hideCandidate = activeLink; 3222 } 3223 if (hideCandidate && !hideCandidate.classList.contains('is-nav-overflow-hidden')) { 3224 hideCandidate.classList.add('is-nav-overflow-hidden'); 3225 guard += 1; 3226 continue; 3227 } 3228 break; 3229 } 3230 3231 if (!useMobileOverflowTier && !hasNavPressure()) { 3232 var hiddenInOrder = links.filter(function (link) { 3233 return link.classList.contains('is-nav-overflow-hidden'); 3234 }); 3235 while (hiddenInOrder.length) { 3236 var revealCandidate = hiddenInOrder[0]; 3237 revealCandidate.classList.remove('is-nav-overflow-hidden'); 3238 if (hasNavPressure()) { 3239 revealCandidate.classList.add('is-nav-overflow-hidden'); 3240 break; 3241 } else { 3242 hiddenInOrder.shift(); 3243 } 3244 } 3245 } 3246 3247 var hiddenLinks = links.filter(function (link) { 3248 return link.classList.contains('is-nav-overflow-hidden'); 3249 }); 3250 if (!hiddenLinks.length) { 3251 els.navOverflowMenu.hidden = true; 3252 setNavOverflowButtonCount(0); 3253 markNavOverflowReady(); 3254 return; 3255 } 3256 3257 hiddenLinks.forEach(function (link) { 3258 var item = document.createElement('a'); 3259 item.className = 'nav-menu-item'; 3260 item.setAttribute('role', 'menuitem'); 3261 item.href = link.getAttribute('href') || '/'; 3262 item.textContent = compact(link.textContent || '') || 'Page'; 3263 item.addEventListener('click', function () { 3264 closeNavOverflowMenu(); 3265 }); 3266 els.navOverflowPanel.appendChild(item); 3267 }); 3268 3269 setNavOverflowButtonCount(hiddenLinks.length); 3270 els.navOverflowMenu.hidden = false;
3271 markNavOverflowReady(); 3272 } 3273 3274 function placeNavOverflowMenuWithPageLinks() { 3275 if (!els.navOverflowMenu) { 3276 return; 3277 } 3278 var nav = document.querySelector('nav.site-nav'); 3279 if (!nav) { 3280 return; 3281 } 3282 var navCenter = nav.querySelector('.nav-center'); 3283 if (!navCenter) { 3284 if (nav.lastElementChild === els.navOverflowMenu) { 3285 return; 3286 } 3287 nav.appendChild(els.navOverflowMenu); 3288 return; 3289 } 3290 if (els.navOverflowMenu.parentNode === navCenter && els.navOverflowMenu.nextElementSibling === null) { 3291 return; 3292 } 3293 navCenter.appendChild(els.navOverflowMenu); 3294 } 3295 3296 function scheduleNavOverflowMenuSync() { 3297 if (state.navOverflowTimer) { 3298 window.clearTimeout(state.navOverflowTimer); 3299 state.navOverflowTimer = 0; 3300 } 3301 if (state.navOverflowRaf && typeof window.cancelAnimationFrame === 'function') { 3302 window.cancelAnimationFrame(state.navOverflowRaf); 3303 state.navOverflowRaf = 0; 3304 } 3305 state.navOverflowTimer = window.setTimeout(function () { 3306 state.navOverflowTimer = 0; 3307 if (typeof window.requestAnimationFrame === 'function') { 3308 state.navOverflowRaf = window.requestAnimationFrame(function () { 3309 state.navOverflowRaf = 0; 3310 syncNavOverflowMenuNow(); 3311 }); 3312 return; 3313 } 3314 syncNavOverflowMenuNow(); 3315 }, 60); 3316 } 3317 3318 function markHydrationNavReady() { 3319 var gate = window.__wizardryHydration; 3320 if (gate && typeof gate.markNavReady === 'function') { 3321 gate.markNavReady(); 3322 } 3323 } 3324 3325 function applyInitialHighlightInSyncWithContent() { 3326 highlightCurrentPage(); 3327 } 3328 3329 function navbarMatchesRows(pageRows) {
3330 var navCenter = document.querySelector('.nav-center'); 3331 if (!navCenter) { 3332 return false; 3333 } 3334 var links = Array.prototype.slice.call(navCenter.querySelectorAll('a[data-page]')); 3335 var rows = (Array.isArray(pageRows) ? pageRows : []).filter(function (page) { 3336 return !!(page && String(page.slug || '').trim() && String(page.path || '').trim()); 3337 }); 3338 if (links.length !== rows.length) { 3339 return false; 3340 } 3341 for (var i = 0; i < rows.length; i += 1) { 3342 var link = links[i]; 3343 var row = rows[i]; 3344 if (String(link.getAttribute('data-page') || '') !== String(row.slug || '').trim()) { 3345 return false; 3346 } 3347 if (normalizeNavPath(link.getAttribute('href') || '') !== normalizeNavPath(String(row.path || '').trim())) { 3348 return false; 3349 } 3350 if (compact(link.textContent || '') !== compact(String(row.title || row.slug || ''))) { 3351 return false; 3352 } 3353 if (String(link.getAttribute('data-nav-rank') || '') !== String(i + 1)) { 3354 return false; 3355 } 3356 } 3357 return true; 3358 } 3359 3360 function renderNavbarNostrPages(pageRows, options) { 3361 var opts = options && typeof options === 'object' ? options : {};
3362 var navCenter = document.querySelector('.nav-center'); 3363 if (!navCenter) { 3364 return; 3365 } 3366 if (navbarMatchesRows(pageRows)) { 3367 placeNavOverflowMenuWithPageLinks(); 3368 syncNavSiteSignatureDestination(); 3369 highlightCurrentPage(); 3370 markNavOverflowReady(); 3371 return; 3372 } 3373 if (state.navOverflowReady && opts.force !== true) { 3374 markNavOverflowReady(); 3375 return; 3376 } 3377 var normalizedCurrent = normalizeNavPath(window.location.pathname); 3378 var html = ''; 3379 var seen = {}; 3380 (Array.isArray(pageRows) ? pageRows : []).forEach(function (page, index) { 3381 var slug = String(page && page.slug || '').trim(); 3382 var title = String(page && page.title || '').trim(); 3383 var path = String(page && page.path || '').trim(); 3384 if (!slug || !path || seen[slug]) { 3385 return; 3386 } 3387 seen[slug] = true; 3388 var isActive = normalizeNavPath(path) === normalizedCurrent; 3389 html += '<a href="' + escapeHtml(path) + '" data-page="' + escapeHtml(slug) + '" data-nav-rank="' + String(index + 1) + '"' + (isActive ? ' class="active" aria-current="page"' : '') + '>' + escapeHtml(title || slug) + '</a>'; 3390 }); 3391 if (html) { 3392 navCenter.innerHTML = html; 3393 } 3394 placeNavOverflowMenuWithPageLinks(); 3395 syncNavSiteSignatureDestination(); 3396 highlightCurrentPage(); 3397 syncNavOverflowMenuNow(); 3398 } 3399 3400 function loadNavbarNostrPages(options) { 3401 var opts = options && typeof options === 'object' ? options : {};
3402 var navCenter = document.querySelector('.nav-center'); 3403 if (!navCenter) { 3404 return Promise.resolve(); 3405 } 3406 3407 var bootstrapPages = opts.useBootstrap === false ? null : readBootstrapNavbarPages(); 3408 if (bootstrapPages) { 3409 try { 3410 localStorage.setItem('cached_navbar_pages_v1', JSON.stringify(bootstrapPages)); 3411 } catch (_bootstrapCacheErr) { 3412 // Ignore storage failures. 3413 } 3414 renderNavbarNostrPages(bootstrapPages); 3415 if (!hasStoredSessionToken()) { 3416 warmNavbarNostrPagePrefetch(); 3417 } 3418 return Promise.resolve(); 3419 } 3420 3421 try { 3422 var cachedRaw = localStorage.getItem('cached_navbar_pages_v1') || ''; 3423 if (cachedRaw) { 3424 var cachedPages = JSON.parse(cachedRaw); 3425 if (Array.isArray(cachedPages) && cachedPages.length) { 3426 renderNavbarNostrPages(cachedPages); 3427 if (!hasStoredSessionToken()) { 3428 warmNavbarNostrPagePrefetch(); 3429 } 3430 } 3431 } 3432 } catch (_cacheReadErr) { 3433 // Ignore invalid cache. 3434 } 3435 3436 return fetch('/cgi/blog-list-navbar-pages', { cache: 'no-store' }) 3437 .then(function (res) { return res.json(); }) 3438 .then(function (data) { 3439 if (!data || !data.success || !Array.isArray(data.pages)) { 3440 return; 3441 } 3442 try { 3443 localStorage.setItem('cached_navbar_pages_v1', JSON.stringify(data.pages)); 3444 } catch (_cacheErr) { 3445 // Ignore storage failures. 3446 } 3447 renderNavbarNostrPages(data.pages); 3448 if (!hasStoredSessionToken()) { 3449 warmNavbarNostrPagePrefetch(); 3450 } 3451 }) 3452 .catch(function () { 3453 // Keep static nav links on fetch failure. 3454 }); 3455 } 3456 3457 window.addEventListener('wizardry-navbar-refresh-request', function (event) { 3458 var detail = event && event.detail ? event.detail : null; 3459 if (detail && Array.isArray(detail.pages)) { 3460 try { 3461 localStorage.setItem('cached_navbar_pages_v1', JSON.stringify(detail.pages)); 3462 } catch (_cacheErr) { 3463 // Ignore storage failures. 3464 } 3465 renderNavbarNostrPages(detail.pages, { force: true }); 3466 if (detail.skipFetch === true) { 3467 return; 3468 } 3469 } 3470 loadNavbarNostrPages({ useBootstrap: false }).catch(function () { 3471 // Keep current navbar when refresh fails. 3472 }); 3473 }); 3474 3475 function normalizeNavPath(path) { 3476 var p = String(path || '').trim(); 3477 var search = ''; 3478 if (!p) { 3479 return '/'; 3480 } 3481 if (p.indexOf('http://') === 0 || p.indexOf('https://') === 0) { 3482 try { 3483 var parsed = new URL(p, window.location.href); 3484 p = parsed.pathname || '/'; 3485 search = parsed.search || ''; 3486 } catch (_err) { 3487 p = '/'; 3488 search = ''; 3489 } 3490 } 3491 if (!search && p.indexOf('?') >= 0) { 3492 var split = p.split('?', 2); 3493 p = split[0] || '/'; 3494 search = split[1] ? ('?' + split[1]) : ''; 3495 } 3496 p = p.replace(/\/+$/, ''); 3497 if (!p) { 3498 p = '/'; 3499 } 3500 if (p === '/pages/index' || p === '/pages/index.html') { 3501 try { 3502 var params = new URLSearchParams(search || ''); 3503 var slug = String(params.get('page_slug') || params.get('slug') || '').trim().toLowerCase(); 3504 slug = slug.replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, ''); 3505 if (slug && slug !== 'index') { 3506 return '/' + slug; 3507 } 3508 } catch (_err2) { 3509 // Ignore malformed page query strings. 3510 } 3511 } 3512 if (p === '/pages/index' || p === '/pages/index.html') { 3513 return '/'; 3514 } 3515 if (p.indexOf('/pages/') === 0) { 3516 p = '/' + p.slice('/pages/'.length); 3517 p = p.replace(/\.html?$/i, ''); 3518 } 3519 if (!p) { 3520 p = '/'; 3521 } 3522 return p; 3523 } 3524 3525 function highlightCurrentPage() { 3526 var currentPath = window.location.href; 3527 var currentHash = window.location.hash || ''; 3528 var navLinks = document.querySelectorAll('.nav-center a[data-page]'); 3529 var normalizedCurrent = normalizeNavPath(currentPath); 3530 var navTargetPath = normalizedCurrent; 3531 var isBlogPostRoute = normalizedCurrent.indexOf('/posts/') === 0 || 3532 normalizedCurrent === '/cgi/blog-open-post' || 3533 normalizedCurrent.indexOf('/cgi/blog-open-post/') === 0; 3534 if (isBlogPostRoute) { 3535 navTargetPath = '/'; 3536 } 3537 var matches = [];
3538 var navCenter = document.querySelector('.nav-center'); 3539 3540 navLinks.forEach(function (link) { 3541 var href = link.getAttribute('href') || ''; 3542 if (normalizeNavPath(href) === navTargetPath) { 3543 matches.push(link); 3544 } 3545 }); 3546 3547 if (navCenter) { 3548 var existingTemp = navCenter.querySelector('a[data-temp-nav-current="true"]'); 3549 if (existingTemp) { 3550 existingTemp.parentNode.removeChild(existingTemp); 3551 } 3552 navLinks = document.querySelectorAll('.nav-center a[data-page]'); 3553 } 3554 3555 var selectedMatch = matches.length ? matches[0] : null; 3556 navLinks.forEach(function (link) { 3557 var active = link === selectedMatch; 3558 link.classList.toggle('active', active); 3559 link.classList.remove('allow-active-click'); 3560 if (active) { 3561 link.setAttribute('aria-current', 'page'); 3562 link.removeAttribute('aria-disabled'); 3563 link.removeAttribute('tabindex'); 3564 } else { 3565 link.removeAttribute('aria-current'); 3566 link.removeAttribute('aria-disabled'); 3567 link.removeAttribute('tabindex'); 3568 } 3569 }); 3570 3571 if (els.composeLink) { 3572 var normalizedComposePath = normalizeNavPath(currentPath); 3573 var onCompose = (normalizedComposePath === '/admin') && currentHash === '#compose'; 3574 els.composeLink.classList.toggle('active', onCompose); 3575 els.composeLink.setAttribute('aria-disabled', onCompose ? 'true' : 'false'); 3576 if (onCompose) { 3577 els.composeLink.setAttribute('tabindex', '-1'); 3578 } else { 3579 els.composeLink.removeAttribute('tabindex'); 3580 } 3581 } 3582 updateUserNameActiveState(); 3583 } 3584 3585 function updateThemeSelect() { 3586 var themeSelect = document.getElementById('theme-select'); 3587 if (themeSelect) { 3588 themeSelect.value = state.currentTheme; 3589 } 3590 } 3591 3592 function pulseThemeSwitchVisualState() { 3593 var root = document.documentElement; 3594 var body = document.body; 3595 if (root) { 3596 root.classList.add('theme-switching'); 3597 } 3598 if (body) { 3599 body.classList.add('theme-switching'); 3600 } 3601 if (themeSwitchVisualTimer) { 3602 clearTimeout(themeSwitchVisualTimer); 3603 } 3604 themeSwitchVisualTimer = setTimeout(function () { 3605 if (root) { 3606 root.classList.remove('theme-switching'); 3607 } 3608 if (body) { 3609 body.classList.remove('theme-switching'); 3610 } 3611 themeSwitchVisualTimer = null; 3612 }, 90); 3613 } 3614 3615 function swapThemeStylesheet(href) { 3616 var themeLink = document.getElementById('theme-stylesheet'); 3617 if (!themeLink || !href) { 3618 return Promise.resolve(); 3619 } 3620 var absoluteHref = href; 3621 try { 3622 absoluteHref = new URL(href, window.location.href).href; 3623 } catch (_err) { 3624 absoluteHref = href; 3625 } 3626 var currentHref = String(themeLink.href || ''); 3627 var currentRequested = String(themeLink.getAttribute('data-theme-href') || ''); 3628 if (currentHref === absoluteHref || currentRequested === href || currentRequested === absoluteHref) { 3629 return Promise.resolve(); 3630 } 3631 3632 var token = ++themeSwapToken; 3633 return new Promise(function (resolve) { 3634 var preloader = document.createElement('link'); 3635 preloader.rel = 'stylesheet'; 3636 preloader.href = href; 3637 preloader.media = 'not all'; 3638 preloader.setAttribute('data-theme-preload', 'true'); 3639 3640 function cleanup() { 3641 if (preloader.parentNode) { 3642 preloader.parentNode.removeChild(preloader); 3643 } 3644 } 3645 3646 function commit() { 3647 if (token !== themeSwapToken) { 3648 cleanup(); 3649 resolve(); 3650 return; 3651 } 3652 themeLink.href = href; 3653 themeLink.setAttribute('data-theme-href', href); 3654 cleanup(); 3655 resolve(); 3656 } 3657 3658 preloader.addEventListener('load', commit, { once: true }); 3659 preloader.addEventListener('error', commit, { once: true }); 3660 (themeLink.parentNode || document.head || document.documentElement).appendChild(preloader); 3661 setTimeout(commit, 1500); 3662 }); 3663 } 3664 3665 function isThemeHrefAlreadyActive(themeLink, href) { 3666 if (!themeLink || !href) { 3667 return true; 3668 } 3669 var absoluteHref = href; 3670 try { 3671 absoluteHref = new URL(href, window.location.href).href; 3672 } catch (_err) { 3673 absoluteHref = href; 3674 } 3675 var currentHref = String(themeLink.href || ''); 3676 var currentRequested = String(themeLink.getAttribute('data-theme-href') || ''); 3677 return currentHref === absoluteHref || currentRequested === href || currentRequested === absoluteHref; 3678 } 3679 3680 function updateThemeStylesheet(theme) { 3681 var nextTheme = normalizeThemeName(theme); 3682 var themeVersion = String(window.__wizardryThemeStylesheetVersion || '20260622-lapidarist-restore1'); 3683 var href = '/static/themes/' + encodeURIComponent(nextTheme) + '.css?v=' + encodeURIComponent(themeVersion); 3684 var themeLink = document.getElementById('theme-stylesheet'); 3685 if (isThemeHrefAlreadyActive(themeLink, href)) { 3686 return Promise.resolve(); 3687 } 3688 pulseThemeSwitchVisualState(); 3689 return swapThemeStylesheet(href); 3690 } 3691 3692 function loadTheme() { 3693 var bootstrapConfig = readBootstrapConfig(); 3694 if (bootstrapConfig) { 3695 applySiteConfig(bootstrapConfig); 3696 return updateThemeStylesheet(state.currentTheme) 3697 .then(function () { 3698 updateThemeSelect(); 3699 }) 3700 .catch(function () { 3701 updateThemeSelect(); 3702 }); 3703 } 3704 3705 return fetch('/cgi/blog-get-config', { cache: 'no-store' }) 3706 .then(function (res) { return res.json(); }) 3707 .then(function (data) { 3708 applySiteConfig(data); 3709 if (data && data.theme) { 3710 return updateThemeStylesheet(state.currentTheme); 3711 } 3712 }) 3713 .then(function () { 3714 updateThemeSelect(); 3715 }) 3716 .catch(function () { 3717 var cached = readCachedPlugins(); 3718 if (cached) { 3719 publishPlugins(cached); 3720 syncPluginAuthUi(); 3721 } 3722 updateThemeSelect(); 3723 }); 3724 } 3725 3726 function saveTheme(theme) { 3727 var params = new URLSearchParams({ theme: theme }); 3728 fetch('/cgi/blog-set-theme', { 3729 method: 'POST', 3730 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 3731 body: params.toString() 3732 }).catch(function () { 3733 // Keep local style even if persistence fails. 3734 }); 3735 } 3736 3737 function bindThemeSelect() { 3738 var themeSelect = document.getElementById('theme-select'); 3739 if (!themeSelect) { 3740 return; 3741 } 3742 3743 function preserveFocus() { 3744 if (document.activeElement === themeSelect) { 3745 return; 3746 } 3747 setTimeout(function () { 3748 try { 3749 themeSelect.focus({ preventScroll: true }); 3750 } catch (_) { 3751 themeSelect.focus(); 3752 } 3753 }, 0); 3754 } 3755 3756 function applySelectedTheme(nextTheme) { 3757 state.currentTheme = normalizeThemeName(nextTheme); 3758 cacheTheme(state.currentTheme); 3759 updateThemeStylesheet(state.currentTheme); 3760 saveTheme(state.currentTheme); 3761 preserveFocus(); 3762 } 3763 3764 themeSelect.addEventListener('keydown', function (event) { 3765 if ((event.key !== 'ArrowDown' && event.key !== 'ArrowUp') || event.altKey || event.ctrlKey || event.metaKey) { 3766 return; 3767 } 3768 var enabledValues = Array.prototype.slice.call(themeSelect.options || []).filter(function (opt) { 3769 return !opt.disabled; 3770 }).map(function (opt) { 3771 return String(opt.value || ''); 3772 }); 3773 if (!enabledValues.length) { 3774 return; 3775 } 3776 var current = String(themeSelect.value || ''); 3777 var currentIndex = enabledValues.indexOf(current); 3778 if (currentIndex < 0) { 3779 currentIndex = 0; 3780 } 3781 var nextIndex = event.key === 'ArrowDown' 3782 ? (currentIndex + 1) % enabledValues.length 3783 : (currentIndex - 1 + enabledValues.length) % enabledValues.length; 3784 var nextTheme = enabledValues[nextIndex]; 3785 if (!nextTheme || nextTheme === current) { 3786 event.preventDefault(); 3787 return; 3788 } 3789 event.preventDefault(); 3790 event.stopPropagation(); 3791 themeSelect.value = nextTheme; 3792 applySelectedTheme(nextTheme); 3793 }); 3794 3795 themeSelect.addEventListener('change', function (event) { 3796 var nextTheme = event.target.value; 3797 applySelectedTheme(nextTheme); 3798 }); 3799 } 3800 3801 function bindUiEvents() { 3802 if (els.loginBtn) { 3803 els.loginBtn.addEventListener('click', function () { 3804 startPrimaryLogin().catch(function (err) { 3805 showNavToast(err.message || 'Nostr signer login failed.', 'info', 4200); 3806 showAuthModal(preferredUnsignedLoginTab()); 3807 }); 3808 }); 3809 } 3810 3811 if (els.loginMoreBtn && els.loginMenu) { 3812 els.loginMoreBtn.addEventListener('click', function (event) { 3813 event.preventDefault(); 3814 event.stopPropagation(); 3815 if (els.loginMenu.hidden) { 3816 openLoginMenu(); 3817 } else { 3818 closeLoginMenu(); 3819 } 3820 }); 3821 } 3822 3823 if (els.loginMenuRegister) { 3824 els.loginMenuRegister.addEventListener('click', function () { 3825 closeLoginMenu(); 3826 showAuthModal('register'); 3827 }); 3828 } 3829 3830 if (els.loginMenuPhone) { 3831 els.loginMenuPhone.addEventListener('click', function () { 3832 closeLoginMenu(); 3833 showAuthModal('phone'); 3834 }); 3835 } 3836 3837 if (els.loginMenuManual) { 3838 els.loginMenuManual.addEventListener('click', function () { 3839 closeLoginMenu(); 3840 showAuthModal('manual'); 3841 setAuthControlsDisabled(true); 3842 prepareManualLogin().catch(function (err) { 3843 setAuthMessage(err.message || 'Failed to prepare manual login.', 'error'); 3844 }).finally(function () { 3845 setAuthControlsDisabled(false); 3846 }); 3847 }); 3848 } 3849 3850 if (els.loginMenuLearn) { 3851 els.loginMenuLearn.addEventListener('click', function () { 3852 closeLoginMenu(); 3853 showAuthModal('register'); 3854 }); 3855 } 3856 3857 if (els.authModal) { 3858 els.authModal.addEventListener('click', function (event) { 3859 if (event.target && event.target.hasAttribute('data-close-auth-modal')) { 3860 hideAuthModal(); 3861 } 3862 }); 3863 } 3864 if (els.menuBtn && els.menuPanel) { 3865 els.menuBtn.addEventListener('click', function (event) { 3866 event.preventDefault(); 3867 event.stopPropagation(); 3868 closeNavOverflowMenu(); 3869 closeLoginMenu(); 3870 if (els.menuPanel.hidden) { 3871 openUserMenu(); 3872 } else {
3873 closeUserMenu(); 3874 } 3875 }); 3876 } 3877 3878 if (els.navOverflowBtn && els.navOverflowPanel) { 3879 els.navOverflowBtn.addEventListener('click', function (event) { 3880 event.preventDefault(); 3881 event.stopPropagation(); 3882 closeLoginMenu(); 3883 closeUserMenu(); 3884 if (els.navOverflowPanel.hidden) { 3885 openNavOverflowMenu(); 3886 } else { 3887 closeNavOverflowMenu(); 3888 } 3889 }); 3890 } 3891 3892 if (els.menuLogoutBtn) { 3893 els.menuLogoutBtn.addEventListener('click', function () { 3894 closeUserMenu(); 3895 logout(); 3896 }); 3897 } 3898 3899 if (els.menuLogoutMoreBtn && els.menuLogoutSubmenu) { 3900 els.menuLogoutMoreBtn.addEventListener('click', function (event) { 3901 event.preventDefault(); 3902 event.stopPropagation(); 3903 if (els.menuLogoutSubmenu.hidden) { 3904 openLogoutSubmenu(); 3905 } else { 3906 closeLogoutSubmenu(); 3907 } 3908 }); 3909 } 3910 3911 if (els.menuLogoutEverywhereBtn) { 3912 els.menuLogoutEverywhereBtn.addEventListener('click', function () { 3913 closeUserMenu(); 3914 setAuthMessage('Preparing log out other sessions challenge...', 'warn'); 3915 logoutEverywhere().catch(function (err) { 3916 setAuthMessage(err.message || 'Log out other sessions failed.', 'error'); 3917 showAuthModal('register'); 3918 }); 3919 }); 3920 } 3921 3922 document.addEventListener('click', function (event) { 3923 if (!els.userMenu || els.userMenu.style.display === 'none') { 3924 if (els.loginSplit && els.loginMenu && !els.loginMenu.hidden && !els.loginSplit.contains(event.target)) { 3925 closeLoginMenu(); 3926 } 3927 if (els.navOverflowMenu && els.navOverflowPanel && !els.navOverflowPanel.hidden && !els.navOverflowMenu.contains(event.target)) { 3928 closeNavOverflowMenu(); 3929 } 3930 return; 3931 } 3932 if (!els.userMenu.contains(event.target)) { 3933 closeUserMenu(); 3934 } else if (els.menuLogoutSubmenu && !els.menuLogoutSubmenu.hidden && !els.menuLogoutSubmenu.contains(event.target) && event.target !== els.menuLogoutMoreBtn && !(els.menuLogoutMoreBtn && els.menuLogoutMoreBtn.contains(event.target))) { 3935 closeLogoutSubmenu(); 3936 } 3937 if (els.loginSplit && els.loginMenu && !els.loginMenu.hidden && !els.loginSplit.contains(event.target)) { 3938 closeLoginMenu(); 3939 } 3940 if (els.navOverflowMenu && els.navOverflowPanel && !els.navOverflowPanel.hidden && !els.navOverflowMenu.contains(event.target)) { 3941 closeNavOverflowMenu(); 3942 } 3943 }); 3944 3945 document.addEventListener('keydown', function (event) { 3946 if (event.key === 'Escape' && els.authModal && !els.authModal.hidden) { 3947 hideAuthModal(); 3948 return; 3949 } 3950 if (event.key === 'Escape' && els.loginMenu && !els.loginMenu.hidden) { 3951 closeLoginMenu(); 3952 } 3953 if (event.key === 'Escape' && els.menuPanel && !els.menuPanel.hidden) { 3954 if (els.menuLogoutSubmenu && !els.menuLogoutSubmenu.hidden) { 3955 closeLogoutSubmenu(); 3956 return; 3957 } 3958 closeUserMenu(); 3959 } 3960 if (event.key === 'Escape' && els.navOverflowPanel && !els.navOverflowPanel.hidden) { 3961 closeNavOverflowMenu(); 3962 } 3963 }); 3964 3965 if (els.authRegisterBtn) { 3966 els.authRegisterBtn.addEventListener('click', function () { 3967 startDesktopSignerLogin(true, '').catch(function (err) { 3968 setAuthMessage(err.message || 'Nostr signer login failed.', 'error'); 3969 }); 3970 }); 3971 } 3972 3973 if (els.authModal) { 3974 els.authModal.addEventListener('click', function (event) { 3975 var trigger = event.target && event.target.closest ? event.target.closest('[data-auth-route]') : null; 3976 if (!trigger || !els.authModal.contains(trigger)) { 3977 return; 3978 } 3979 event.preventDefault(); 3980 setActiveAuthTab(trigger.getAttribute('data-auth-route'), trigger.getAttribute('data-auth-flavor')); 3981 }); 3982 } 3983 3984 function copyNip46Uri() { 3985 return initNip46Pairing().then(function () { 3986 return refreshUnpairedNip46Link(); 3987 }).then(function () { 3988 var uri = currentNip46Uri(); 3989 if (!uri) { 3990 throw new Error('Nostr Connect link is not ready yet.'); 3991 } 3992 if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { 3993 return navigator.clipboard.writeText(uri); 3994 } 3995 throw new Error('Clipboard access is unavailable. Select and copy the link text below.'); 3996 }).then(function () { 3997 setAuthMessage('Nostr Connect link copied.', 'ok'); 3998 }).catch(function (err) { 3999 setAuthMessage(err.message || 'Could not copy Nostr Connect link.', 'error'); 4000 }); 4001 } 4002 4003 if (els.authNip46UriCopy) { 4004 els.authNip46UriCopy.addEventListener('click', copyNip46Uri); 4005 } 4006 4007 if (els.authNip46Open) { 4008 els.authNip46Open.addEventListener('click', function (event) { 4009 event.preventDefault(); 4010 if (!state.nip46.signerPubkey && !hasNostrTools()) { 4011 setAuthMessage('Phone signer setup is still loading. Tap Connect Nostr again in a moment.', 'w
4011arn'); 4012 initNip46Pairing().catch(function (err) { 4013 setAuthMessage(err.message || 'Could not prepare a fresh Nostr Connect link.', 'error'); 4014 }); 4015 return; 4016 } 4017 var uri = String(els.authNip46Open.getAttribute('data-nip46-uri') || els.authNip46Open.getAttribute('href') || ''); 4018 if (uri.indexOf('nostrconnect://') === 0) { 4019 openNativeDeepLink(uri, 'Nostr Connect link is not ready yet. The QR setup is still loading.'); 4020 return; 4021 } 4022 rotateUnpairedNip46StateNow(); 4023 refreshUnpairedNip46Link().then(function () { 4024 var freshUri = currentNip46Uri(); 4025 openNativeDeepLink(freshUri, 'Nostr Connect link is not ready yet. The QR setup is still loading.'); 4026 }).catch(function (err) { 4027 setAuthMessage(err && err.message ? err.message : 'Could not prepare a Nostr Connect link.', 'error'); 4028 }); 4029 }); 4030 } 4031 4032 if (els.authNip46Reset) { 4033 els.authNip46Reset.addEventListener('click', function () { 4034 setAuthMessage('Making a fresh phone signer link...', 'warn'); 4035 resetPhonePairingLink().then(function () { 4036 updatePhoneContinueState(); 4037 setAuthMessage('Fresh phone signer link is ready. Open the app link or scan the QR.', 'ok'); 4038 }).catch(function (err) { 4039 setAuthMessage(err.message || 'Could not make a fresh phone signer link.', 'error'); 4040 }); 4041 }); 4042 } 4043 4044 if (els.authPhoneBtn) { 4045 els.authPhoneBtn.addEventListener('click', function () { 4046 continuePhoneSignerLogin(false); 4047 }); 4048 } 4049 4050 if (els.authManualStart) { 4051 els.authManualStart.addEventListener('click', function () { 4052 setAuthControlsDisabled(true); 4053 prepareManualLogin().catch(function (err) { 4054 setAuthMessage(err.message || 'Failed to create manual challenge.', 'error'); 4055 }).finally(function () { 4056 setAuthControlsDisabled(false); 4057 }); 4058 }); 4059 } 4060 4061 if (els.authManualSubmit) { 4062 els.authManualSubmit.addEventListener('click', function () { 4063 setAuthMessage('Verifying pasted signed login...', 'warn'); 4064 setAuthControlsDisabled(true); 4065 Promise.resolve() 4066 .then(function () { 4067 return submitManualLogin(); 4068 }) 4069 .catch(function (err) { 4070 setAuthMessage(err.message || 'Manual login failed.', 'error'); 4071 }) 4072 .finally(function () { 4073 setAuthControlsDisabled(false); 4074 }); 4075 }); 4076 } 4077 4078 document.addEventListener('visibilitychange', function () { 4079 if (!document.hidden) { 4080 refreshPhoneSignerListenerAfterReturn('visibility'); 4081 } 4082 }); 4083 4084 window.addEventListener('pageshow', function () { 4085 refreshPhoneSignerListenerAfterReturn('pageshow'); 4086 }); 4087 4088 window.addEventListener('focus', function () { 4089 refreshPhoneSignerListenerAfterReturn('focus'); 4090 }); 4091 4092 window.addEventListener('resize', function () { 4093 if (els.authModal && !els.authModal.hidden) { 4094 syncAuthModalNavOffset(); 4095 } 4096 }); 4097 4098 if (window.visualViewport) { 4099 window.visualViewport.addEventListener('resize', function () { 4100 if (els.authModal && !els.authModal.hidden) { 4101 syncAuthModalNavOffset(); 4102 } 4103 }); 4104 } 4105 } 4106 4107 function bootstrap() { 4108 var optimisticIsLoggedIn = false; 4109 var optimisticIsAdmin = false; 4110 var optimisticName = ''; 4111 var optimisticSiteTitle = ''; 4112 try { 4113 optimisticIsLoggedIn = hasStoredSessionToken(); 4114 optimisticIsAdmin = String(localStorage.getItem('last_auth_is_admin') || '')
4114=== '1'; 4115 optimisticName = String(localStorage.getItem('last_auth_player_name') || localStorage.getItem('last_auth_username') || '').trim(); 4116 optimisticSiteTitle = String(localStorage.getItem(SITE_TITLE_CACHE_KEY) || '').trim(); 4117 } catch (_err) { 4118 optimisticIsLoggedIn = false; 4119 optimisticIsAdmin = false; 4120 optimisticName = ''; 4121 optimisticSiteTitle = ''; 4122 } 4123 updateNavSiteSignature(optimisticSiteTitle); 4124 var cachedPlugins = readCachedPlugins(); 4125 if (cachedPlugins) { 4126 publishPlugins(cachedPlugins); 4127 } else { 4128 window.__wizardryPlugins = state.plugins; 4129 window.__wizardryVideoChatEnabled = !!state.plugins.video_chat; 4130 } 4131 state.isAuthenticated = optimisticIsLoggedIn; 4132 applyLoggedInUi(optimisticIsLoggedIn, optimisticIsAdmin, optimisticName); 4133 placeNavOverflowMenuWithPageLinks(); 4134 4135 renderComposeIcon(readComposeIconIndex()); 4136 highlightCurrentPage(); 4137 applyInitialHighlightInSyncWithContent(); 4138 if (!optimisticIsLoggedIn) { 4139 warmNavbarNostrPagePrefetch(); 4140 } 4141 loadNavbarNostrPages() 4142 .then(function () { 4143 highlightCurrentPage(); 4144 }) 4145 .catch(function () { 4146 // Keep startup resilient; cached nav remains usable. 4147 }); 4148 checkAuth(); 4149 loadTheme(); 4150 window.addEventListener('hashchange', highlightCurrentPage); 4151 markNavOverflowReady(); 4152 bindThemeSelect(); 4153 bindUiEvents(); 4154 bindNavbarNostrPagePrefetch(); 4155 window.blogAuth = window.blogAuth || {}; 4156 window.blogAuth.openLoginModal = showAuthModal; 4157 window.blogAuth.startLogin = function (options) { 4158 var showFallbackModal = !(options && options.fallbackModal === false); 4159 return startPrimaryLogin(options).catch(function (err) { 4160 showNavToast(err && err.message ? err.message : 'Nostr signer login failed.', 'info', 4200); 4161 if (showFallbackModal) { 4162 showAuthModal(preferredUnsignedLoginTab()); 4163 } 4164 throw err; 4165 }); 4166 }; 4167 window.blogAuth.showToast = showNavToast; 4168 flushRememberedNavToast(); 4169 } 4170 4171 document.addEventListener('DOMContentLoaded', bootstrap); 4172})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.