/** * The browser half of on-demand indexing. * * One request — `POST /api/index` — with the two callers that make it: the 404 * resolver, which asks on the reader's behalf when they open a deep link this build has * never heard of, and the review-by-URL dialog, where they ask deliberately. * * Both need the same three things and neither should reimplement them: the request, a * plain sentence for every way it can fail, and the `ReviewSubject` the review dialog * publishes against. The failure vocabulary itself is `@cashumints/shared`, so the API * and this file cannot drift about what `wrong_type` means. */ import { fedimintSubject, lnurlSubject, mintSubject, type ReviewSubject, } from './review-subject'; import { apiBase } from './client'; import type { Translator } from '../i18n/translate'; import { isIndexFailure, type IndexFailure, type IndexSuccess, type IndexType, type FedimintDetail, type LnurlDetail, type MintDetail, } from '@cashumints/shared'; /** * What came back. * * The HTTP status is kept beside the body rather than collapsed into a boolean, because * 200 and 201 mean visibly different things to a reader — "already listed" against "we * just checked it for you" — and the dialog says so. */ export type IndexResult = | { ok: true; status: number; mint: IndexSuccess } | { ok: false; status: number; failure: IndexFailure }; /** A network failure, shaped like every other failure so callers have one path. */ function offline(): IndexResult { return { ok: false, status: 0, failure: { error: 'invalid_response', message: 'the API could not be reached' }, }; } /** * Ask the API to index an address, and wait for the answer. * * There is deliberately no client-side timeout. The endpoint bounds itself — one probe * at the standard five seconds, then at most three more for the relay lookup — and a * shorter timer here would abandon requests that are about to succeed while leaving the * server doing the work anyway. The caller shows a "checking" state and means it. */ export async function requestIndex(type: IndexType, input: string): Promise { let res: Response; try { res = await fetch(`${apiBase}/api/index`, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ type, input }), }); } catch { return offline(); } let body: unknown; try { body = (await res.json()) as unknown; } catch { return offline(); } if (!body || typeof body !== 'object') return offline(); if (res.ok && !isIndexFailure(body as IndexSuccess | IndexFailure)) { return { ok: true, status: res.status, mint: body as IndexSuccess }; } return { ok: false, status: res.status, failure: body as IndexFailure }; } /** * The sentence a reader gets for a failure. * * Plain words, and specific: "nothing responded at this address and no trace of it * exists on Nostr yet" is a different situation from "that address answered, but not as * an LNURL mint", and a reader can act on the difference — the first means checking the * spelling, the second means picking a different page. A generic "could not be added" * would hide both. * * `wrong_type` is handled by the caller rather than here, because the useful response to * it is a button, not a sentence. */ export function indexErrorMessage(result: { status: number; failure: IndexFailure }, t: Translator): string { const { failure, status } = result; switch (failure.error) { case 'bad_input': return t('reviews.byUrl.error.badInput'); case 'bad_type': return t('reviews.byUrl.error.badInput'); case 'blocked_host': return t('reviews.byUrl.error.blocked'); case 'invalid_invite': return t('reviews.byUrl.error.badInvite'); case 'invalid_response': return status === 0 ? t('reviews.byUrl.error.network') : t('reviews.byUrl.error.notAMint'); case 'unverifiable': return t('reviews.byUrl.error.unverifiable'); case 'rate_limited': return t('reviews.byUrl.error.rateLimited', { n: retryMinutes(failure) }); case 'wrong_type': return t('reviews.byUrl.error.wrongType'); default: return t('reviews.byUrl.error.network'); } } /** A `retry_after` in whole minutes, at least one, for the "try again in…" sentence. */ function retryMinutes(failure: IndexFailure): number { return Math.max(1, Math.ceil((failure.retry_after ?? 3600) / 60)); } /** * The review subject for an indexed mint, whichever ecosystem it belongs to. * * The same three builders the three page templates use, chosen by the payload's own * `type` rather than by what the caller asked for — a submission can come back as a * different ecosystem than it went out as (that is what `detected_type` is for), and * the row that exists is the one the review has to point at. */ export function subjectFor(mint: MintDetail): ReviewSubject | null { if (mint.type === 'fedimint') return fedimintSubject(mint as FedimintDetail); if (mint.type === 'lnurl') return lnurlSubject(mint as LnurlDetail); if (mint.type === 'cashu') return mintSubject(mint); // A type this build has no review kind for. Nothing here can point a review at it. return null; } /** * The name to show for a mint, falling back the way every card does. * * A federation usually has one from its announcement; one submitted as a bare invite * code has nothing but its id, and its shortened id is then both its name and its * address. Callers that render both lines drop the second when they match, rather than * printing the same string twice. */ export function mintDisplayName(mint: MintDetail): string { const domain = displayAddress(mint); return mint.name ?? domain.split('/')[0] ?? domain; } /** * The address to print under that name. * * A federation has no URL — its key is `fedimint:` — so it shows its id instead, * shortened the way the federation pages shorten it. Everything else shows the address * a reader would type. */ export function displayAddress(mint: MintDetail): string { if (mint.type === 'fedimint') { const id = mint.federation_id ?? mint.host; return id.length > 24 ? `${id.slice(0, 16)}…${id.slice(-6)}` : id; } return mint.url.replace(/^lnurl:/, '').replace(/^https?:\/\//, '').replace(/\/+$/, ''); }