Add many new language packs with RTL support, refresh brand assets, and harden review rendering with tests. Co-authored-by: Cursor <cursoragent@cursor.com>
481 lines
16 KiB
TypeScript
481 lines
16 KiB
TypeScript
/**
|
|
* 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 openApp = el<HTMLAnchorElement>('[data-connect-open]');
|
|
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 = '';
|
|
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<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();
|
|
}
|