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>
167 lines
6.5 KiB
TypeScript
167 lines
6.5 KiB
TypeScript
/**
|
|
* Which listing a review event is about, for the two feeds that read kind 38000 with no
|
|
* subject filter at all: the /reviews island and the build-time prerender behind it.
|
|
*
|
|
* A per-subject read asks the relays a filter per mint or per federation. A feed cannot:
|
|
* "reviews of any of these seventy things" is hundreds of filters, so it asks for recent
|
|
* 38000 wholesale and works out afterwards what each event points at. That resolution is
|
|
* this file, shared by the browser and the build so a card cannot resolve one way at
|
|
* build time and another way a second later when the island redraws it.
|
|
*
|
|
* Events that resolve to nothing are dropped rather than shown. A review of a mint or a
|
|
* federation this site has never indexed has nowhere to link to, and a card that cannot
|
|
* say what it is about is not worth a row.
|
|
*
|
|
* No `t()` here: `check-i18n.mjs` treats every `.ts` under `lib/` as island code, and
|
|
* there is nothing to translate in a lookup anyway.
|
|
*/
|
|
import {
|
|
baseUrlFromKey, federationIdFromKey, federationIdFromSlug, hostIdentifier, mintUrlsFromEvent,
|
|
normalizeMintUrl, reviewEcosystem, reviewTargetId,
|
|
type MintListItem, type NostrEventLike,
|
|
} from '@cashumints/shared';
|
|
import { displayDomain } from './format';
|
|
|
|
/** One indexed listing, with enough to resolve a review to it and then link to it. */
|
|
export interface FeedTarget {
|
|
/** Row key: a mint URL, or `fedimint:<id>`. */
|
|
url: string;
|
|
/** Routing slug. */
|
|
host: string;
|
|
name: string;
|
|
/** The label a card prints: a domain, or a shortened federation id. */
|
|
domain: string;
|
|
/** `cashu`, `fedimint` or `lnurl`. Picks both the resolver and the link's route. */
|
|
type: string;
|
|
/**
|
|
* The `d` identifier reviews of this thing carry, when that is how it is found.
|
|
*
|
|
* A federation id, or an LNURL mint's normalized host. null for a Cashu mint, which is
|
|
* resolved through its `u` tag instead — that is how every legacy review points at
|
|
* one, and changing it would strand them.
|
|
*/
|
|
ref: string | null;
|
|
}
|
|
|
|
/** Both lookups a feed needs, built once per read rather than per event. */
|
|
export interface FeedIndex {
|
|
byUrl: Map<string, FeedTarget>;
|
|
byRef: Map<string, FeedTarget>;
|
|
}
|
|
|
|
export function buildFeedIndex(targets: readonly FeedTarget[]): FeedIndex {
|
|
const byUrl = new Map<string, FeedTarget>();
|
|
const byRef = new Map<string, FeedTarget>();
|
|
for (const target of targets) {
|
|
byUrl.set(target.url, target);
|
|
if (target.ref) byRef.set(target.ref.toLowerCase(), target);
|
|
}
|
|
return { byUrl, byRef };
|
|
}
|
|
|
|
/**
|
|
* The listing a review points at, or null.
|
|
*
|
|
* The `k` tag picks the resolver, and it has to: a federation id and a mint's pubkey are
|
|
* both 64 hex characters, so a `d`-first resolver with no ecosystem check could file a
|
|
* federation review against a mint. An event with no `k` is Cashu — every one of those
|
|
* predates this site listing anything else — and one naming a kind this build has no
|
|
* ecosystem for belongs to neither and is dropped.
|
|
*/
|
|
export function resolveFeedTarget(event: NostrEventLike, index: FeedIndex): FeedTarget | null {
|
|
const ecosystem = reviewEcosystem(event);
|
|
|
|
if (ecosystem === 'fedimint') {
|
|
const d = reviewTargetId(event);
|
|
return d ? index.byRef.get(d.toLowerCase()) ?? null : null;
|
|
}
|
|
|
|
/*
|
|
* LNURL resolves by `d` and then by `u`, because both are genuinely used.
|
|
*
|
|
* The `d` a review carries is either the mint's funding-node pubkey or its host, and
|
|
* the feed index only knows the host — the list payload carries no pubkey. So a
|
|
* pubkey-`d` review falls through to `u`, which an LNURL review can actually be
|
|
* resolved by: unlike a federation's opaque invite code, it is a real URL that the
|
|
* same normalizer the Cashu branch uses can turn into a row key.
|
|
*/
|
|
if (ecosystem === 'lnurl') {
|
|
const d = reviewTargetId(event);
|
|
const byRef = d ? index.byRef.get(d.trim().toLowerCase()) : undefined;
|
|
if (byRef) return byRef;
|
|
|
|
for (const raw of mintUrlsFromEvent(event)) {
|
|
const normalized = normalizeMintUrl(raw);
|
|
if (!normalized) continue;
|
|
const found = index.byUrl.get(`lnurl:${normalized.url}`);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (ecosystem !== 'cashu') return null;
|
|
|
|
const raw = mintUrlsFromEvent(event)[0];
|
|
if (!raw) return null;
|
|
const normalized = normalizeMintUrl(raw);
|
|
if (!normalized) return null;
|
|
return index.byUrl.get(normalized.url) ?? null;
|
|
}
|
|
|
|
/**
|
|
* The page a card links to. One route per ecosystem, decided in one place.
|
|
*
|
|
* The default is `/mint/`, not the newest ecosystem: a row of some type this build has
|
|
* never heard of is far more likely to be an older one, and every row written before
|
|
* `mints.type` existed means Cashu.
|
|
*/
|
|
const ROUTES: Record<string, string> = {
|
|
fedimint: '/fedimint',
|
|
lnurl: '/lnurl-mint',
|
|
cashu: '/mint',
|
|
};
|
|
|
|
export function targetPath(target: Pick<FeedTarget, 'type' | 'host'>): string {
|
|
return `${ROUTES[target.type] ?? '/mint'}/${target.host}`;
|
|
}
|
|
|
|
/**
|
|
* Turn the two list payloads into what a feed resolves against.
|
|
*
|
|
* A federation's `ref` is not fetched from anywhere: its row key already *is*
|
|
* `fedimint:<federation id>`, so the full 64-character id a review's `d` tag carries is
|
|
* sitting in the list payload. That is worth knowing, because the routing slug holds
|
|
* only the first sixteen and matching on a prefix would have been the obvious wrong
|
|
* answer here.
|
|
*
|
|
* Order is preserved and mints come first, which is the order the caller passes and the
|
|
* order /reviews shows before its own sort.
|
|
*/
|
|
export function feedTargets(...lists: Array<readonly MintListItem[]>): FeedTarget[] {
|
|
return lists.flat().map((item) => {
|
|
const federationId = federationIdFromKey(item.url);
|
|
const lnurlBase = baseUrlFromKey(item.url);
|
|
|
|
/*
|
|
* An LNURL row's key is `lnurl:https://host`, so the label has to come out of the
|
|
* key rather than off `item.url` directly — `displayDomain` on the raw key would
|
|
* print the scheme prefix at a reader. Its `ref` is the host identifier, which is
|
|
* the `d` a review carries whenever the mint has no funding-node pubkey; reviews
|
|
* that carry the pubkey instead resolve through `u`, which is why this does not
|
|
* need one.
|
|
*/
|
|
const domain = federationId
|
|
? federationIdFromSlug(item.host)
|
|
: displayDomain(lnurlBase ?? item.url);
|
|
|
|
return {
|
|
url: item.url,
|
|
host: item.host,
|
|
name: item.name ?? domain.split('/')[0] ?? domain,
|
|
domain,
|
|
type: item.type,
|
|
ref: federationId ?? (lnurlBase ? hostIdentifier(lnurlBase) : null),
|
|
};
|
|
});
|
|
}
|