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:
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user