Add lnurl list/detail routes, OG fixtures, i18n strings, and the write/ index client flows so the site surfaces the new mint type end to end. Co-authored-by: Cursor <cursoragent@cursor.com>
173 lines
6.3 KiB
TypeScript
173 lines
6.3 KiB
TypeScript
/**
|
|
* 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<IndexResult> {
|
|
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:<id>` — 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(/\/+$/, '');
|
|
}
|