@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user