Ship LNURL mint pages, indexing UI, and reviews rewrite.
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>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 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(/\/+$/, '');
|
||||
}
|
||||
Reference in New Issue
Block a user