Files
eranos/src/hooks/useCommunityMembers.ts
T
lemon 527b31247b Restore moderation trust boundary and remove unsafe cache seeding
Two fixes prompted by external review:

1. resolveCommunityModeration now takes the community A tag and filters
   events by matching `A` tag as its first pass. The previous change
   removed the A-tag existence check from parseCommunityReport on the
   assumption that callers scope by relay `#A` filter; that was an
   invariant of the current callers, not a property of the API. Moving
   the check to the resolver restores the trust boundary at the public
   API surface while keeping parseCommunityReport a pure single-event
   parser. The activity feed's pre-grouping pass is dropped since the
   resolver now handles per-community filtering itself.

2. Drop the `['community-members', aTag]` cache seeding from the
   activity feed. The activity feed uses shared relay limits across
   every subscribed community (500 awards and 500 reports total), so
   per-community results can be truncated. Seeding the per-community
   members cache with incomplete data would silently corrupt membership,
   authority, and moderation state on community detail pages.
   useCommunityMembers remains the authoritative per-community fetch.
2026-04-27 09:22:08 -07:00

107 lines
3.8 KiB
TypeScript

import { useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import {
type CommunityMember,
type CommunityMembership,
type CommunityModeration,
type ParsedCommunity,
BADGE_AWARD_KIND,
EMPTY_MEMBERSHIP,
EMPTY_MODERATION,
EMPTY_RANK_MAP,
REPORT_KIND,
resolveCommunityModeration,
resolveMembership,
} from '@/lib/communityUtils';
interface CommunityMembersResult {
/** Resolved membership with banned members removed. Use `members` to list active community members. */
membership: CommunityMembership;
/** Resolved moderation data (bans, reports, content warnings). */
moderation: CommunityModeration;
/** Chain-validated rank lookup (pubkey → rank) BEFORE moderation overlay. Includes banned members. Used for authority checks only — do NOT use to list active members. */
rankMap: Map<string, CommunityMember>;
}
/**
* Fetch and resolve the full membership tree and moderation state for a community.
*
* Queries badge awards (kind 8) and reports (kind 1984),
* then runs the chain validation algorithm with moderation overlay.
*/
export function useCommunityMembers(community: ParsedCommunity | null | undefined) {
const { nostr } = useNostr();
const query = useQuery<CommunityMembersResult>({
queryKey: ['community-members', community?.aTag ?? ''],
queryFn: async ({ signal }) => {
if (!community) {
return {
membership: EMPTY_MEMBERSHIP,
moderation: EMPTY_MODERATION,
rankMap: new Map(),
};
}
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(10_000)]);
// Collect all badge a-tag coordinates from the community definition
const badgeATags = community.ranks
.filter((r) => r.badgeATag)
.map((r) => r.badgeATag!);
// Fetch awards and reports in parallel
const [awards, reports] = await Promise.all([
badgeATags.length > 0
? nostr.query(
[{ kinds: [BADGE_AWARD_KIND], '#a': badgeATags, limit: 500 }],
{ signal: combinedSignal },
)
: Promise.resolve([]),
nostr.query(
[{ kinds: [REPORT_KIND], '#A': [community.aTag], limit: 500 }],
{ signal: combinedSignal },
),
]);
// Step 1-2: Resolve full membership (needed for authority checks)
const fullMembership = resolveMembership(community, awards);
// Build rank lookup for authority checks (includes all chain-validated members, even those later banned)
const rankMap = new Map<string, CommunityMember>();
for (const m of fullMembership.members) {
rankMap.set(m.pubkey, m);
}
// Step 3: Resolve moderation using the rank map. The resolver
// filters by `A` tag internally; we pass all reports as-is since
// the relay query already scoped them to this community.
const moderation = resolveCommunityModeration(community.aTag, reports, rankMap);
// Step 4: Apply moderation overlay — filter banned members from the
// already-computed membership rather than re-running chain validation.
const membership: CommunityMembership = {
members: fullMembership.members.filter(
(m) => !moderation.bannedPubkeys.has(m.pubkey),
),
};
return { membership, moderation, rankMap };
},
enabled: !!community,
staleTime: 2 * 60_000,
});
// Provide backward-compatible access to the membership data
return useMemo(() => ({
data: query.data?.membership,
moderation: query.data?.moderation ?? EMPTY_MODERATION,
rankMap: (query.data?.rankMap ?? EMPTY_RANK_MAP) as Map<string, CommunityMember>,
isLoading: query.isLoading,
isError: query.isError,
error: query.error,
}), [query.data, query.isLoading, query.isError, query.error]);
}