Page community awards and reports exhaustively

Introduce queryAll, a portable helper that exhausts a Nostr filter by
paging with the until cursor, capped at 5,000 events / 10 pages so
worst-case cost stays bounded. Works against any relay regardless of
its internal page size.

Migrate useCommunityMembers and useCommunityActivityFeed so membership
and moderation state are complete for any community that fits within
the cap, instead of silently truncating at 500 events.
This commit is contained in:
lemon
2026-05-06 21:45:20 -07:00
parent 891cf72af8
commit 064e0832df
3 changed files with 123 additions and 18 deletions
+15 -14
View File
@@ -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<string, Map<string, CommunityMember>>();
const reportAuthorSet = new Set<string>();
@@ -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 },
)
: [];
+11 -4
View File
@@ -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 },
);
+97
View File
@@ -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<NostrEvent[]>;
}
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<NostrEvent[]> {
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<string>();
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;
}