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,
};
}