diff --git a/src/hooks/useCommunityActivityFeed.ts b/src/hooks/useCommunityActivityFeed.ts index 83bb4c99..0e8f8289 100644 --- a/src/hooks/useCommunityActivityFeed.ts +++ b/src/hooks/useCommunityActivityFeed.ts @@ -16,6 +16,7 @@ import { } from '@/lib/communityUtils'; import { ZAP_GOAL_KIND } from '@/lib/goalUtils'; import { getPaginationCursor } from '@/lib/feedUtils'; +import { queryAll } from '@/lib/queryAll'; /** Internal result type — events plus per-community moderation/membership data. */ interface ActivityFeedResult { @@ -87,6 +88,9 @@ export function useCommunityActivityFeed() { })); // Fetch community definitions, comments, membership awards, and goals in parallel. + // Awards are exhausted per-community with `queryAll` so every community's + // membership is complete, regardless of how many communities the user + // belongs to. See src/lib/queryAll.ts. const [definitionEvents, comments, awards, goals] = await Promise.all([ // The community definitions themselves nostr.query( @@ -108,13 +112,12 @@ export function useCommunityActivityFeed() { }], { signal: combinedSignal }, ), - // Flat membership awards, scoped by each community's authorized awarders. + // Flat membership awards, one exhaustive query per community. awardFilters.length > 0 - ? nostr.query( - awardFilters, - { signal: combinedSignal }, - ) - : Promise.resolve([]), + ? Promise.all( + awardFilters.map((f) => queryAll(nostr, f, { signal: combinedSignal })), + ).then((pages) => pages.flat()) + : Promise.resolve([] as NostrEvent[]), // NIP-75 zap goals linked to these communities (lowercase a tag) nostr.query( [{ @@ -134,12 +137,9 @@ export function useCommunityActivityFeed() { // only be filtered from community A's posts, not from community B. // // We do **not** seed the `['community-members', aTag]` cache from - // this hook. The activity feed uses shared relay limits across all - // subscribed communities (awards and reports both capped at 500 - // total), so per-community results can be incomplete. Overwriting - // the members cache with incomplete data would silently corrupt - // membership, authority, and moderation state on detail pages. - // `useCommunityMembers` is the authoritative per-community fetch. + // this hook. Even with exhaustive `queryAll` paging, the per-community + // fetch in `useCommunityMembers` may apply different filters or + // trigger a fresh read; keeping it authoritative avoids stale writes. const rankMapByATag = new Map>(); const reportAuthorSet = new Set(); @@ -157,8 +157,9 @@ export function useCommunityActivityFeed() { } const reports = reportAuthorSet.size > 0 - ? await nostr.query( - [{ kinds: [REPORT_KIND], authors: [...reportAuthorSet], '#A': aTags, limit: 500 }], + ? await queryAll( + nostr, + { kinds: [REPORT_KIND], authors: [...reportAuthorSet], '#A': aTags, limit: 500 }, { signal: combinedSignal }, ) : []; diff --git a/src/hooks/useCommunityMembers.ts b/src/hooks/useCommunityMembers.ts index 5b2fb16c..7bccbd2f 100644 --- a/src/hooks/useCommunityMembers.ts +++ b/src/hooks/useCommunityMembers.ts @@ -15,6 +15,7 @@ import { resolveCommunityModeration, resolveMembership, } from '@/lib/communityUtils'; +import { queryAll } from '@/lib/queryAll'; interface CommunityMembersResult { /** Resolved membership with banned members removed. Use `members` to list active community members. */ @@ -49,9 +50,14 @@ export function useCommunityMembers(community: ParsedCommunity | null | undefine const awardAuthors = [community.founderPubkey, ...community.moderatorPubkeys]; + // Exhaustive paging: awards and reports are unbounded sets that grow + // with the community. `queryAll` pages with `until` until the relay + // drains, capped at 5_000 events / 10 pages so worst-case cost is + // bounded. See src/lib/queryAll.ts. const awards = community.memberBadgeATag - ? await nostr.query( - [{ kinds: [BADGE_AWARD_KIND], authors: awardAuthors, '#a': [community.memberBadgeATag], limit: 500 }], + ? await queryAll( + nostr, + { kinds: [BADGE_AWARD_KIND], authors: awardAuthors, '#a': [community.memberBadgeATag], limit: 500 }, { signal: combinedSignal }, ) : []; @@ -66,8 +72,9 @@ export function useCommunityMembers(community: ParsedCommunity | null | undefine } const reportAuthors = fullMembership.members.map((member) => member.pubkey); - const reports = await nostr.query( - [{ kinds: [REPORT_KIND], authors: reportAuthors, '#A': [community.aTag], limit: 500 }], + const reports = await queryAll( + nostr, + { kinds: [REPORT_KIND], authors: reportAuthors, '#A': [community.aTag], limit: 500 }, { signal: combinedSignal }, ); diff --git a/src/lib/queryAll.ts b/src/lib/queryAll.ts new file mode 100644 index 00000000..3de8d401 --- /dev/null +++ b/src/lib/queryAll.ts @@ -0,0 +1,97 @@ +import type { NostrEvent, NostrFilter } from '@nostrify/nostrify'; + +/** + * Minimal Nostr query interface that `queryAll` needs. Matches the shape of + * `useNostr().nostr` as well as `nostr.relay()` / `nostr.group()` results, + * so the helper is portable across any pool/relay/group handle. + */ +interface NostrQueryable { + query(filters: NostrFilter[], opts?: { signal?: AbortSignal }): Promise; +} + +interface QueryAllOptions { + /** + * Hard cap on total events collected. Protects against runaway relays + * that never stop returning events. Default: 5_000. + */ + maxEvents?: number; + /** + * Hard cap on paged round-trips. Also protects against misbehaving relays + * (e.g. ones that return duplicates instead of advancing). Default: 10. + */ + maxPages?: number; + /** + * Abort signal forwarded to each page query. + */ + signal?: AbortSignal; +} + +/** + * Query a Nostr pool/relay/group exhaustively by paging with the `until` + * cursor, stopping when the relay returns fewer events than the filter's + * `limit` (indicating the underlying set is drained) or when either hard + * cap is reached. + * + * The filter's `limit` is used as the page size. Callers SHOULD set it + * explicitly; relays may interpret missing `limit` very differently. + * + * Caps exist so we bound worst-case work regardless of relay behaviour: + * - `maxEvents` — total events across all pages. + * - `maxPages` — total round-trips. + * + * Deduplication happens by `event.id`. A relay returning a duplicate page + * (no forward progress on the cursor) terminates the loop early. + * + * Returns events in the order they were received across pages. Callers + * that need a stable order should sort the result. + * + * This helper intentionally accepts a single filter object — the `until` + * cursor has to be applied per-filter, so a multi-filter query cannot be + * paged as a single pool. If you need to exhaust multiple independent + * filters, call `queryAll` once per filter and merge the results. + */ +export async function queryAll( + nostr: NostrQueryable, + filter: NostrFilter, + opts: QueryAllOptions = {}, +): Promise { + const { maxEvents = 5_000, maxPages = 10, signal } = opts; + const pageSize = filter.limit; + if (!pageSize || pageSize <= 0) { + throw new Error('queryAll: filter.limit must be a positive integer'); + } + + const collected: NostrEvent[] = []; + const seen = new Set(); + let until = filter.until; + + for (let page = 0; page < maxPages; page++) { + const pageFilter: NostrFilter = until !== undefined + ? { ...filter, until } + : filter; + + const events = await nostr.query([pageFilter], { signal }); + + let newCount = 0; + let oldest = Infinity; + for (const ev of events) { + if (seen.has(ev.id)) continue; + seen.add(ev.id); + collected.push(ev); + newCount++; + if (ev.created_at < oldest) oldest = ev.created_at; + if (collected.length >= maxEvents) return collected; + } + + // Stop when the relay indicates the set is drained (short page) or + // when we made no forward progress (all duplicates). + if (events.length < pageSize) return collected; + if (newCount === 0) return collected; + + // Advance the cursor one second past the oldest seen event. Using + // `oldest - 1` avoids re-fetching the boundary event on the next page. + until = oldest - 1; + } + + return collected; +}