Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"fake-indexeddb": "^6.2.5",
"postcss": "^8.5.10",
"tailwindcss": "^3.4.19",
"vite": "^8.0.8"
Expand Down
80 changes: 20 additions & 60 deletions packages/app/src/components/MintList.tsx
Original file line number Diff line number Diff line change
@@ -1,77 +1,37 @@
import {
type AnnouncementRow,
type BitcoinmintsDB,
type MintAggregateRow,
type MintInfoRow,
rankMints,
} from "@bitcoinmints/core";
import type { BitcoinmintsDB } from "@bitcoinmints/core";
import { useLiveQuery } from "dexie-react-hooks";
import type { JSX } from "react";
import { MintRow } from "./MintRow";
import { type MintListRow, queryMintList } from "./mintListQuery";

/**
* The whole list surface for PR #6 — spec is raw field dump per mint,
* sorted by `bayesianScore` DESC via `rankMints(db, 50)`.
* with `verifiedBySignerBinding === false` rendered as an "unverified"
* badge (handled downstream in `MintRow`), NOT filtered out.
*
* Join strategy: one unified `useLiveQuery` at this level pre-joins
* aggregate → announcement → mintInfo and hands `<MintRow>` fully-resolved
* props. Previously we had a per-row `useLiveQuery` which resolved a
* microtask after the aggregate query, causing a ~1s "(no announcement)"
* placeholder flash on reload. Single query kills the flash.
* All the query + join logic lives in `mintListQuery.ts` so it can be
* unit-tested against fake-indexeddb without mounting React. This
* component is a thin render shell: live-query → map to `<MintRow>`.
*
* Prior behavior (for the historians): we called `rankMints(db, 50)` and
* iterated the aggregates. That approach is aggregate-required — any
* mint without a review at all had no row to join from, so it was never
* visible. sharegap.net (0 reviews, Layer B failed) fell into that gap.
* The capture of 36 announcements → 0 rendered was this bug. See
* `mintListQuery.ts` for the fix: announcements-driven, with synthesized
* zero-aggregates for un-reviewed mints.
*/
type Props = {
db: BitcoinmintsDB;
};

type JoinedRow = {
aggregate: MintAggregateRow;
announcement: AnnouncementRow | undefined;
info: MintInfoRow | undefined;
};

export function MintList({ db }: Props): JSX.Element {
// Order: rankMints() returns aggregates sorted by bayesianScore DESC.
// A mint with no reviews yet has no aggregate row, so this list can
// lag behind `announcements` — that's intentional. PR #7 will decide
// whether to render un-reviewed announcements as a tail section; for
// the X-ray we follow the ranked-aggregate-as-truth posture.
//
// Render-filter note: we drop non-Cashu announcements (kind !== 38172)
// below so the X-ray matches spec v1 (Cashu-only). The parse layer still
// stores k=38173 (Fedimint) rows and `rankMints` still ranks them — PR
// #7+ may surface those elsewhere. Keep this filter in the UI; do NOT
// push it into `rankMints` (don't mutate core for a UI-only concern).
const rows = useLiveQuery<JoinedRow[], JoinedRow[]>(
async () => {
const aggregates = await rankMints(db, 50);
const ds = aggregates.map((a) => a.d);
const announcements = await db.announcements.where("d").anyOf(ds).toArray();
const infos = await db.mintInfo.bulkGet(ds);
// A single `d` CAN map to multiple announcements (different pubkeys).
// Map.set keeps whichever appears LAST in `toArray()`; that matches
// the previous per-row `.first()` behavior only by luck-of-insert-order.
// PR #7 will resolve the ambiguity properly.
const annByD = new Map(announcements.map((a) => [a.d, a]));
// bulkGet returns an array in the same order as the keys; index align.
return aggregates.map((agg, i) => ({
aggregate: agg,
announcement: annByD.get(agg.d),
info: infos[i],
}));
},
[db],
[],
);

// Render-only Cashu filter (see note above). Orphan aggregates (no
// announcement — shouldn't happen in practice) are also dropped
// defensively so we never flash a "(no announcement)" row.
const visible = rows.filter((r) => r.announcement !== undefined && r.announcement.kind === 38172);
const rows = useLiveQuery<MintListRow[], MintListRow[]>(() => queryMintList(db), [db], []);

// Empty state per spec: stats block still renders (that's in App.tsx),
// the `mints` header always renders, and if there's nothing to show the
// single line `no mints yet` sits below it.
if (visible.length === 0) {
if (rows.length === 0) {
return (
<>
<div>mints</div>
Expand All @@ -83,13 +43,13 @@ export function MintList({ db }: Props): JSX.Element {
return (
<>
<div>mints</div>
{visible.map((row, i) => (
{rows.map((row, i) => (
<MintRow
key={row.aggregate.d}
key={row.announcement.d}
aggregate={row.aggregate}
announcement={row.announcement}
info={row.info}
isLast={i === visible.length - 1}
isLast={i === rows.length - 1}
/>
))}
</>
Expand Down
Loading
Loading