diff --git a/src/components/CampaignCard.tsx b/src/components/CampaignCard.tsx index bea0204e..33ff36ec 100644 --- a/src/components/CampaignCard.tsx +++ b/src/components/CampaignCard.tsx @@ -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 ( +
+ +
+ + +
+
+ ); + } + 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 ? ( ) : ( - + )}
diff --git a/src/hooks/useCampaignLists.ts b/src/hooks/useCampaignLists.ts index f92582fb..92f3a732 100644 --- a/src/hooks/useCampaignLists.ts +++ b/src/hooks/useCampaignLists.ts @@ -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({ - 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({ + 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(() => { + 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, }; }