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
+27 -31
View File
@@ -15,14 +15,12 @@ import {
KIND_REVIEW,
PROFILE_RELAYS,
cleanReviewContent,
isCashuMintReview,
normalizeMintUrl,
mintUrlsFromEvent,
parseProfileContent,
parseRating,
WRITTEN_MIN_CHARS,
type Profile,
} from '@cashumints/shared';
import { buildFeedIndex, resolveFeedTarget, type FeedTarget } from './feed-resolve';
const list = (value: string | undefined): string[] | null => {
const parsed = value?.split(',').map((relay) => relay.trim()).filter(Boolean);
@@ -47,6 +45,8 @@ export interface BuildReview {
content: string;
mint_url: string;
mint_host: string;
/** `cashu` or `fedimint`: which route the card links to, and which filter shows it. */
mint_type: string;
profile: Profile | null;
}
@@ -186,15 +186,19 @@ function readReviewEvents(): Promise<NostrEvent[]> {
}
/**
* Newest review events that are worth showing, resolved against the mints the API
* knows about. `hostByUrl` maps normalized mint URL to routing slug.
* Newest review events that are worth showing, resolved against everything the API
* knows about — mints and federations both, since the strip's heading is "latest
* reviews" and not "latest mint reviews". In practice federations rarely reach it: the
* junk filter above wants a written body, and almost every 38173 review on the network
* is a bare `[5/5]`.
*/
export async function fetchLatestReviews(
hostByUrl: Map<string, string>,
targets: readonly FeedTarget[],
limit: number,
): Promise<BuildReview[]> {
try {
const events = await readReviewEvents();
const index = buildFeedIndex(targets);
const candidates: BuildReview[] = [];
const seenAuthors = new Set<string>();
@@ -202,15 +206,9 @@ export async function fetchLatestReviews(
for (const event of [...events].sort((a, b) => b.created_at - a.created_at)) {
const content = cleanReviewContent(event.content);
if (!qualifies(content)) continue;
if (!isCashuMintReview(event) && mintUrlsFromEvent(event).length === 0) continue;
const raw = mintUrlsFromEvent(event)[0];
if (!raw) continue;
const normalized = normalizeMintUrl(raw);
if (!normalized) continue;
const host = hostByUrl.get(normalized.url);
if (!host) continue;
const target = resolveFeedTarget(event, index);
if (!target) continue;
// One card per author, so the strip is not three posts from the same npub.
if (seenAuthors.has(event.pubkey)) continue;
@@ -223,8 +221,9 @@ export async function fetchLatestReviews(
created_at: event.created_at,
rating: parseRating(event),
content,
mint_url: normalized.url,
mint_host: host,
mint_url: target.url,
mint_host: target.host,
mint_type: target.type,
profile: null,
});
@@ -261,7 +260,7 @@ export async function fetchLatestReviews(
}
/**
* The global feed: newest written reviews across every mint.
* The global feed: newest written reviews across every mint and federation.
*
* Same source and same parsing as the home page strip, three differences:
* every author may appear more than once (once per mint they reviewed), the junk
@@ -275,33 +274,29 @@ export async function fetchLatestReviews(
* visitor, only for a crawler.
*/
export async function fetchReviewFeed(
hostByUrl: Map<string, string>,
targets: readonly FeedTarget[],
limit: number,
): Promise<BuildReview[]> {
try {
const events = await readReviewEvents();
const index = buildFeedIndex(targets);
/*
* Kind 38000 is addressable: a later event from the same author about the same
* mint replaces the earlier one, exactly as the mint page treats it. Keyed on
* author plus mint, so one person reviewing three mints keeps three reviews.
* subject replaces the earlier one, exactly as a subject page treats it. Keyed on
* author plus subject, so one person reviewing three mints and a federation keeps
* four reviews.
*/
const newest = new Map<string, BuildReview>();
for (const event of events) {
const content = cleanReviewContent(event.content);
if (content.length < WRITTEN_MIN_CHARS) continue;
if (!isCashuMintReview(event) && mintUrlsFromEvent(event).length === 0) continue;
const raw = mintUrlsFromEvent(event)[0];
if (!raw) continue;
const normalized = normalizeMintUrl(raw);
if (!normalized) continue;
const target = resolveFeedTarget(event, index);
if (!target) continue;
const host = hostByUrl.get(normalized.url);
if (!host) continue;
const key = `${event.pubkey}:${host}`;
const key = `${event.pubkey}:${target.host}`;
const existing = newest.get(key);
if (existing && existing.created_at >= event.created_at) continue;
@@ -312,8 +307,9 @@ export async function fetchReviewFeed(
created_at: event.created_at,
rating: parseRating(event),
content,
mint_url: normalized.url,
mint_host: host,
mint_url: target.url,
mint_host: target.host,
mint_type: target.type,
profile: null,
});
}