home: decouple funding-bar skeleton from card, parallelize list queries

CampaignCard now paints immediately and shows a dedicated skeleton for
the funding/progress bar while useCampaignDonations resolves, instead of
flashing a misleading "0 raised" before the on-chain balance lands.

useCampaignLists no longer serializes behind useCampaignModerators: the
list relay query fires immediately on the hashtag filter and the
moderator allowlist is applied client-side in foldCampaignLists. The two
single-relay round-trips (each up to an 8s EOSE timeout) now run in
parallel on cold sessions. The trust gate is unchanged — a list authored
by a non-moderator is dropped before it reaches the UI.
This commit is contained in:
Chad Curtis
2026-06-01 15:26:21 -05:00
committed by filemon
parent be3c6fd3eb
commit 670ef9a3e9
2 changed files with 66 additions and 24 deletions
+27 -2
View File
@@ -47,13 +47,33 @@ function CampaignProgress({
raisedSats,
goalUsd,
btcPrice,
isLoading,
className,
}: {
raisedSats: number;
goalUsd?: number;
btcPrice?: number;
/**
* True while the donation totals are still being fetched. The bar gets
* its own skeleton — independent of the card, which paints immediately —
* so we never flash a misleading "0 raised" before the on-chain balance
* lands. Footprint matches the loaded state (bar row + one text row).
*/
isLoading?: boolean;
className?: string;
}) {
if (isLoading) {
return (
<div className={cn('space-y-1.5', className)}>
<Skeleton className="h-2 w-full" />
<div className="flex items-baseline justify-between gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-24" />
</div>
</div>
);
}
const hasGoal = !!goalUsd && goalUsd > 0;
const raisedUsd = satsToUsd(raisedSats, btcPrice);
const pct = hasGoal && raisedUsd !== undefined
@@ -151,7 +171,7 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
});
const displayCampaign = parseCampaign(translatedEvent) ?? campaign;
const author = useAuthor(campaign.pubkey);
const { data: stats } = useCampaignDonations(campaign);
const { data: stats, isLoading: donationsLoading } = useCampaignDonations(campaign);
const { data: btcPrice } = useBtcPrice();
const naddr = useMemo(() => encodeCampaignNaddr(campaign), [campaign]);
@@ -287,7 +307,12 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
{isSilentPayment ? (
<CampaignPrivateNotice goalUsd={campaign.goalUsd} />
) : (
<CampaignProgress raisedSats={raisedSats} goalUsd={campaign.goalUsd} btcPrice={btcPrice} />
<CampaignProgress
raisedSats={raisedSats}
goalUsd={campaign.goalUsd}
btcPrice={btcPrice}
isLoading={donationsLoading}
/>
)}
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
+39 -22
View File
@@ -26,11 +26,23 @@ interface UseCampaignListsResult {
* `agora.campaign-list` hashtag) plus the optional list-of-lists order
* sentinel (`agora.campaign-lists.index`).
*
* **Trust model.** The query gates `authors:` on
* {@link useCampaignModerators}'s allowlist (Team Soapbox follow pack
* members). Without that gate, any pubkey could publish a kind 30003
* with our hashtag and appear in the strip — same self-appointment hole
* we avoid in `useCampaignModeration`.
* **Trust model.** Only lists authored by a {@link useCampaignModerators}
* allowlist member (Team Soapbox follow pack) are surfaced. Without that
* gate, any pubkey could publish a kind 30003 with our hashtag and appear
* in the strip — same self-appointment hole we avoid in
* `useCampaignModeration`.
*
* **Flattened waterfall.** The list relay query no longer *waits* for the
* moderator pack to resolve. Previously the query was `enabled`-gated on
* the moderators and applied `authors: moderators` server-side, which
* serialized two single-relay round-trips (each up to an 8s EOSE timeout)
* on a cold session before any list could render. We now fire the list
* query immediately on the hashtag filter and apply the moderator
* allowlist **client-side** in {@link foldCampaignLists}. The two queries
* run in parallel; the trust gate is identical (a list authored by a
* non-moderator is dropped before it ever reaches the UI). The moderator
* pack is cached for 10 minutes, so on warm sessions it's already present
* and the filter applies with zero added latency.
*
* Lists *and* the index are pulled in a single filter via
* `'#t': [LIST_HASHTAG, LIST_INDEX_HASHTAG]` so there's only one
@@ -40,43 +52,48 @@ export function useCampaignLists() {
const { nostr } = useNostr();
const { data: moderators, isLoading: moderatorsLoading } = useCampaignModerators();
const moderatorsKey = useMemo(
() => (moderators ? [...moderators].sort().join(',') : ''),
[moderators],
);
const query = useQuery<UseCampaignListsResult>({
queryKey: ['campaign-lists', moderatorsKey],
enabled: !!moderators && moderators.length > 0,
// Raw lists query — fired independently of the moderator pack so the two
// round-trips run in parallel. The moderator allowlist is applied at the
// fold step below, not as a server `authors:` filter.
const rawQuery = useQuery<NostrEvent[]>({
queryKey: ['campaign-lists', 'raw'],
queryFn: async ({ signal }) => {
if (!moderators || moderators.length === 0) {
return { lists: [], indexEvent: undefined };
}
// Query the canonical app relay directly. The same reasoning as
// `useCampaignModerators` applies: a fast empty EOSE from a
// less-populated relay should not race the moderation surface to
// "no lists" while the curated relay still holds them.
const relay = nostr.relay(DITTO_RELAY);
const events = await relay.query(
return relay.query(
[
{
kinds: [CAMPAIGN_LIST_KIND],
authors: moderators,
'#t': [CAMPAIGN_LIST_HASHTAG, CAMPAIGN_LIST_INDEX_HASHTAG],
limit: 500,
},
],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
);
return foldCampaignLists(events);
},
staleTime: 30_000,
});
// Fold + trust-gate client-side: drop any list/index event not authored
// by a moderator before parsing. Recomputed when either the raw events
// or the moderator allowlist changes.
const data = useMemo<UseCampaignListsResult>(() => {
const events = rawQuery.data;
if (!events || !moderators || moderators.length === 0) {
return { lists: [], indexEvent: undefined };
}
const allowed = new Set(moderators);
const trusted = events.filter((e) => allowed.has(e.pubkey));
return foldCampaignLists(trusted);
}, [rawQuery.data, moderators]);
return {
...query,
isLoading: query.isLoading || moderatorsLoading,
...rawQuery,
data,
isLoading: rawQuery.isLoading || moderatorsLoading,
};
}