Expand ecash explorer capabilities

Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
This commit is contained in:
michilis
2026-08-21 02:10:48 +02:00
parent aa1771ea20
commit 6f17b572b1
80 changed files with 7580 additions and 704 deletions
+116
View File
@@ -0,0 +1,116 @@
/**
* 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 {
federationIdFromKey, federationIdFromSlug, 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` or `fedimint`. 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. null for a 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;
}
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. */
export function targetPath(target: Pick<FeedTarget, 'type' | 'host'>): string {
return `/${target.type === 'fedimint' ? 'fedimint' : '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 domain = federationId ? federationIdFromSlug(item.host) : displayDomain(item.url);
return {
url: item.url,
host: item.host,
name: item.name ?? domain.split('/')[0] ?? domain,
domain,
type: item.type,
ref: federationId,
};
});
}