first commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
michilis
2026-08-20 22:41:25 +02:00
co-authored by Cursor
commit aa1771ea20
136 changed files with 27069 additions and 0 deletions
+467
View File
@@ -0,0 +1,467 @@
/**
* 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 =
`<span class="avatar" style="background:${iconGradient(pubkey)};width:${size}px;height:${size}px;` +
`font-size:${Math.round(size * 0.44)}px" aria-hidden="true">${escapeHtml(initial)}</span>`;
const picture = profile?.found ? profile.picture : null;
if (!picture) return `<span class="id-avatar">${tile}</span>`;
return (
`<span class="id-avatar">${tile}<img class="avatar pic" src="${escapeHtml(picture)}" alt=""` +
` width="${size}" height="${size}" style="width:${size}px;height:${size}px" loading="lazy"` +
` referrerpolicy="no-referrer" onload="this.classList.add('loaded')" onerror="this.remove()"></span>`
);
}
/** 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 `<span class="id-method">${escapeHtml(methodLabel(method, t))}</span>`;
}
/** npub for a hex pubkey. bech32 is only pulled in once someone is actually logged in. */
export async function toNpub(pubkey: string): Promise<string> {
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<Profile | null> {
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<T extends HTMLElement>(selector: string): T | null {
return dialog?.querySelector<T>(selector) ?? null;
}
function setStatus(message: string, tone: 'busy' | 'error' | 'done' | 'none' = 'busy'): void {
const box = el<HTMLElement>('[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<HTMLElement>('[data-login-error]');
if (!box) return;
box.textContent = message;
box.hidden = message === '';
}
function step(name: 'choose' | 'connect'): void {
for (const panel of dialog?.querySelectorAll<HTMLElement>('[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<HTMLButtonElement>('[data-login-method]')?.focus();
}
export function closeLogin(): void {
if (dialog) closeModal(dialog);
}
/* ---------- the three flows ---------- */
async function runExtension(): Promise<void> {
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 (
'<a href="https://primal.net/downloads" rel="noopener noreferrer" target="_blank">' +
`${escapeHtml(t('login.getPrimal'))}</a>`
);
}
async function runConnect(method: 'remote' | 'primal'): Promise<void> {
const t = useI18n();
cancelPairing();
showError('');
step('connect');
const lead = el<HTMLElement>('[data-connect-lead]');
const hint = el<HTMLElement>('[data-connect-hint]');
const qr = el<HTMLElement>('[data-connect-qr]');
const uriBox = el<HTMLElement>('[data-connect-uri]');
const copy = el<HTMLButtonElement>('[data-connect-copy]');
const title = el<HTMLElement>('[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 = '';
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;
}
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<void> {
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<HTMLElement>('[data-auth]');
}
function loggedOutHtml(): string {
return (
'<button class="auth-login" type="button" data-login-open>' +
`${escapeHtml(useI18n()('login.open'))}</button>`
);
}
function loggedInHtml(session: Session, npub: string, profile: Profile | null): string {
const t = useI18n();
const { primary: name, named } = identityLines(profile, npub, t);
return (
`<button class="auth-user" type="button" data-auth-menu-button aria-haspopup="true"` +
` aria-expanded="false" aria-controls="auth-menu">` +
`${avatarMarkup(session.pubkey, profile, npub)}` +
`<span class="auth-name${named ? '' : ' mono'}">${escapeHtml(name)}</span>` +
`<svg class="auth-caret" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"` +
` stroke-width="2.5" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>` +
`</button>` +
`<div class="auth-menu" id="auth-menu" data-auth-menu hidden>` +
// The npub is a row of its own below, so the head only names someone who has a
// name to be named by.
`<p class="auth-menu-head">${escapeHtml(named ? name : t('login.account'))}` +
`<span>${escapeHtml(methodLabel(session.method, t))}</span></p>` +
`<button class="auth-menu-row npub" type="button" data-copy-id="${escapeHtml(npub)}"` +
` title="${escapeHtml(npub)}" aria-label="${escapeHtml(t('login.copyNpub'))}">` +
`<span class="cid-text mono">${escapeHtml(shortNpub(npub))}</span></button>` +
`<button class="auth-menu-row" type="button" data-logout>${escapeHtml(t('login.logout'))}</button>` +
`</div>`
);
}
/**
* 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<void> {
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<HTMLButtonElement>('[data-auth-menu-button]');
const menu = root?.querySelector<HTMLElement>('[data-auth-menu]');
if (!button || !menu) return;
menuOpen = open;
menu.hidden = !open;
button.setAttribute('aria-expanded', String(open));
if (open) menu.querySelector<HTMLButtonElement>('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<HTMLElement>('[data-auth-menu]');
if (!menu || menu.hidden) return;
const items = [...menu.querySelectorAll<HTMLButtonElement>('button')];
const index = items.indexOf(document.activeElement as HTMLButtonElement);
if (event.key === 'Escape') {
event.preventDefault();
setMenu(false);
root?.querySelector<HTMLButtonElement>('[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<HTMLDialogElement>('[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<HTMLElement>('[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<HTMLButtonElement>('[data-login-method]')?.focus();
return;
}
const method = target.closest<HTMLButtonElement>('[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<HTMLElement>('[data-auth-menu-button]');
if (menuButton) {
setMenu(!menuOpen);
return;
}
if (target.closest('[data-bunker-connect]')) {
const input = dialog?.querySelector<HTMLInputElement>('#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<HTMLInputElement>('#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();
}