/** * The login popup and the header's account control. * * Shipped on every page, so the static half of this module is deliberately thin: * nothing about NIP-46, QR encoding or bech32 is imported until a reader actually * starts a login. A logged-out visitor reading reviews downloads a few hundred bytes * of this and nothing else. * * Everything it renders is public: a pubkey, a name, a picture. No key material of * any kind reaches this file. */ import type { Profile } from '@cashumints/shared'; import { escapeHtml, iconGradient, shortNpub, truncateMiddle, wireCopyButtons } from './client'; import { useI18n } from '../i18n/client'; import type { Translator } from '../i18n/translate'; import { closeModal, openModal, wireModal } from './modal'; import { clearSession, connectBunker, connectExtension, hasExtension, onSessionChange, onSignerStatus, readSession, signerErrorMessage, startNostrConnect, type ConnectHandle, type Session, type SignerMethod, } from './nostr-signer'; /* ---------- identity rendering, shared with the review dialog ---------- */ function profileName(profile: Profile | null): string | null { const name = profile?.found ? (profile.display_name ?? profile.name) : null; return name && name.trim() ? name.trim() : null; } /** * The two lines an identity is shown on, wherever it appears. * * With a kind 0 name the npub goes underneath it, because the name is for reading and * the npub is for checking. Without one the npub is the name, and repeating it * underneath would be saying the same thing twice. */ export function identityLines( profile: Profile | null, npub: string, t: Translator = useI18n(), ): { primary: string; secondary: string; named: boolean } { const name = profileName(profile); // The name is the reader's own, out of their kind 0, and is never touched. Only the // phrase standing in when there is no name is site copy. return name ? { primary: name, secondary: shortNpub(npub), named: true } : { primary: shortNpub(npub), secondary: t('login.account'), named: false }; } /** * The account avatar: a generated tile from the pubkey, with the kind 0 picture over * it once it decodes. Same two-layer trick as a review card, so a missing or dead * picture costs nothing and moves nothing. */ export function avatarMarkup(pubkey: string, profile: Profile | null, npub: string, size = 26): string { const name = profile?.found ? (profile.display_name ?? profile.name) : null; const initial = (name?.[0] ?? npub[5] ?? '?').toUpperCase(); const tile = ``; const picture = profile?.found ? profile.picture : null; if (!picture) return `${tile}`; return ( `${tile}` ); } /** How the reader is signing, said in one word. "Primal" is a product name and stays. */ export function methodLabel(method: SignerMethod, t: Translator = useI18n()): string { return t(`login.method.${method}`); } export function methodChipHtml(method: SignerMethod, t: Translator = useI18n()): string { return `${escapeHtml(methodLabel(method, t))}`; } /** npub for a hex pubkey. bech32 is only pulled in once someone is actually logged in. */ export async function toNpub(pubkey: string): Promise { try { const { npubEncode } = await import('nostr-tools/nip19'); return npubEncode(pubkey); } catch { return pubkey; } } /** The profile for one pubkey, cache first, never throwing. */ export async function resolveProfile(pubkey: string): Promise { try { const { loadProfiles } = await import('./profiles'); return (await loadProfiles([pubkey])).get(pubkey) ?? null; } catch { return null; } } /* ---------- the login popup ---------- */ let dialog: HTMLDialogElement | null = null; /** The pairing currently on screen, so leaving the step can call it off. */ let pairing: ConnectHandle | null = null; /** Where focus was when the popup opened, so it can go back there. */ let opener: HTMLElement | null = null; function el(selector: string): T | null { return dialog?.querySelector(selector) ?? null; } function setStatus(message: string, tone: 'busy' | 'error' | 'done' | 'none' = 'busy'): void { const box = el('[data-login-status]'); if (!box) return; box.textContent = message; box.className = `login-status${tone === 'none' ? '' : ` is-${tone}`}`; box.hidden = message === ''; } function showError(message: string): void { const box = el('[data-login-error]'); if (!box) return; box.textContent = message; box.hidden = message === ''; } function step(name: 'choose' | 'connect'): void { for (const panel of dialog?.querySelectorAll('[data-login-step]') ?? []) { panel.hidden = panel.dataset['loginStep'] !== name; } } /** Leave the pairing step, calling off whatever it started. */ function cancelPairing(): void { pairing?.cancel(); pairing = null; } export function openLogin(from?: HTMLElement | null): void { if (!dialog) return; opener = from ?? (document.activeElement as HTMLElement | null); showError(''); setStatus('', 'none'); step('choose'); openModal(dialog); el('[data-login-method]')?.focus(); } export function closeLogin(): void { if (dialog) closeModal(dialog); } /* ---------- the three flows ---------- */ async function runExtension(): Promise { const t = useI18n(); showError(''); setStatus(t('login.status.waitingExtension'), 'busy'); try { await connectExtension(); setStatus('', 'none'); closeLogin(); } catch (error) { setStatus('', 'none'); showError(signerErrorMessage(error)); } } /** * The one link this screen's copy carries, built here so the catalog only has to say * where in the sentence it goes. */ function primalDownloadLink(t: Translator): string { return ( '' + `${escapeHtml(t('login.getPrimal'))}` ); } async function runConnect(method: 'remote' | 'primal'): Promise { const t = useI18n(); cancelPairing(); showError(''); step('connect'); const lead = el('[data-connect-lead]'); const hint = el('[data-connect-hint]'); const qr = el('[data-connect-qr]'); const uriBox = el('[data-connect-uri]'); const copy = el('[data-connect-copy]'); const openApp = el('[data-connect-open]'); const title = el('[data-connect-title]'); if (title) title.textContent = t(`login.connect.${method}.title`); if (lead) lead.textContent = t(`login.connect.${method}.lead`); /* * Catalog copy, with one anchor this file built, interpolated into it. Nothing here * comes from a relay, a URL or the reader, and the only value substituted in is the * link above, whose text goes through escapeHtml on the way. */ if (hint) hint.innerHTML = t(`login.connect.${method}.hint`, { link: primalDownloadLink(t) }); if (qr) qr.innerHTML = ''; if (openApp) openApp.hidden = true; // A link from the last pairing would be a dead one. setStatus(t('login.status.buildingLink'), 'busy'); let handle: ConnectHandle; try { handle = await startNostrConnect(method); } catch (error) { setStatus('', 'none'); showError(signerErrorMessage(error)); return; } pairing = handle; const { qrSvg } = await import('./qr'); if (qr) qr.innerHTML = qrSvg(handle.uri); if (uriBox) uriBox.textContent = truncateMiddle(handle.uri, 28, 12); if (copy) { copy.dataset['copy'] = handle.uri; copy.hidden = false; } if (openApp) { /* * The tap-to-open route for a signer on this same device. Primal registers its * own primalconnect:// scheme and parses it identically to nostrconnect://, so * the Primal button opens Primal itself rather than the system's app chooser; * the generic route keeps nostrconnect://, which signer apps register. */ openApp.href = method === 'primal' ? handle.uri.replace(/^nostrconnect:/, 'primalconnect:') : handle.uri; openApp.textContent = t(`login.connect.${method}.open`); openApp.hidden = false; } setStatus(t('login.status.waitingSigner'), 'busy'); try { await handle.session; if (pairing !== handle) return; // The reader went back; this pairing is stale. pairing = null; setStatus(t('login.status.connected'), 'done'); closeLogin(); } catch (error) { if (pairing !== handle) return; pairing = null; setStatus('', 'none'); showError(signerErrorMessage(error)); } } async function runBunkerPaste(input: string): Promise { const t = useI18n(); showError(''); setStatus(t('login.status.connecting'), 'busy'); try { await connectBunker(input, 'remote'); setStatus(t('login.status.connected'), 'done'); closeLogin(); } catch (error) { setStatus('', 'none'); showError(signerErrorMessage(error)); } } /* ---------- the header control ---------- */ let menuOpen = false; function headerRoot(): HTMLElement | null { return document.querySelector('[data-auth]'); } function loggedOutHtml(): string { return ( '` ); } function loggedInHtml(session: Session, npub: string, profile: Profile | null): string { const t = useI18n(); const { primary: name, named } = identityLines(profile, npub, t); return ( `` + `` ); } /** * Draw the header for the session as it stands. * * Runs twice on a restored session: once from the cache, immediately, so the avatar * and name are there at first paint, and again if the relays turn up something newer. * Neither pass changes the control's height. */ async function renderHeader(): Promise { const root = headerRoot(); if (!root) return; const session = readSession(); if (!session) { menuOpen = false; root.innerHTML = loggedOutHtml(); return; } const npub = await toNpub(session.pubkey); const cached = await resolveProfile(session.pubkey); if (readSession()?.pubkey !== session.pubkey) return; // Logged out while we waited. root.innerHTML = loggedInHtml(session, npub, cached); } function setMenu(open: boolean): void { const root = headerRoot(); const button = root?.querySelector('[data-auth-menu-button]'); const menu = root?.querySelector('[data-auth-menu]'); if (!button || !menu) return; menuOpen = open; menu.hidden = !open; button.setAttribute('aria-expanded', String(open)); if (open) menu.querySelector('button')?.focus(); } /** Arrow keys walk the menu, Escape shuts it and hands focus back to its button. */ function menuKeys(event: KeyboardEvent): void { const root = headerRoot(); const menu = root?.querySelector('[data-auth-menu]'); if (!menu || menu.hidden) return; const items = [...menu.querySelectorAll('button')]; const index = items.indexOf(document.activeElement as HTMLButtonElement); if (event.key === 'Escape') { event.preventDefault(); setMenu(false); root?.querySelector('[data-auth-menu-button]')?.focus(); return; } if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); const next = event.key === 'ArrowDown' ? index + 1 : index - 1; items[(next + items.length) % items.length]?.focus(); } } /* ---------- wiring ---------- */ let wired = false; /** * Wire the header and the popup. Called on first paint and after every view * transition; the document-level listeners are attached once, the header is redrawn * every time because the router replaces it. */ export function initAuth(): void { dialog = document.querySelector('[data-login-dialog]'); void renderHeader(); if (dialog) { // The connection link's copy button. Its value is set when a pairing starts; the // handler reads it at click time, so wiring it once here is enough. wireCopyButtons(dialog); wireModal(dialog, { onClose: () => { cancelPairing(); opener?.focus(); opener = null; }, }); } if (wired) return; wired = true; onSessionChange(() => { void renderHeader(); }); // The signer talks back through here: reconnecting, waiting, approve-in-a-window. onSignerStatus((update) => { if (!dialog?.open) return; setStatus(update.message, update.state === 'error' ? 'error' : update.state === 'ready' ? 'done' : 'busy'); }); document.addEventListener('click', (event) => { const target = event.target as HTMLElement | null; if (!target) return; const open = target.closest('[data-login-open]'); if (open) { event.preventDefault(); openLogin(open); return; } if (target.closest('[data-login-close]')) { closeLogin(); return; } if (target.closest('[data-login-back]')) { cancelPairing(); showError(''); setStatus('', 'none'); step('choose'); el('[data-login-method]')?.focus(); return; } const method = target.closest('[data-login-method]'); if (method) { const kind = method.dataset['loginMethod'] as SignerMethod; if (kind === 'extension') void runExtension(); else void runConnect(kind === 'primal' ? 'primal' : 'remote'); return; } const menuButton = target.closest('[data-auth-menu-button]'); if (menuButton) { setMenu(!menuOpen); return; } if (target.closest('[data-bunker-connect]')) { const input = dialog?.querySelector('#bunker-input'); void runBunkerPaste(input?.value ?? ''); return; } if (target.closest('[data-logout]')) { clearSession(); return; } // A click anywhere else closes an open account menu. if (menuOpen && !target.closest('[data-auth-menu]')) setMenu(false); }); document.addEventListener('keydown', menuKeys); // Enter in the paste box does what the Connect button does. There is no form here // on purpose (see LoginDialog.astro), so this is the whole of "submitting" it. document.addEventListener('keydown', (event) => { if (event.key !== 'Enter') return; const input = (event.target as HTMLElement).closest('#bunker-input'); if (!input) return; event.preventDefault(); void runBunkerPaste(input.value); }); } /** True when the extension option should be offered. Checked on open, not at build. */ export function extensionAvailable(): boolean { return hasExtension(); }