/** * 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:`. */ 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; byRef: Map; } export function buildFeedIndex(targets: readonly FeedTarget[]): FeedIndex { const byUrl = new Map(); const byRef = new Map(); 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 = { fedimint: '/fedimint', lnurl: '/lnurl-mint', cashu: '/mint', }; export function targetPath(target: Pick): 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:`, 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): 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), }; }); }