Improve community moderation robustness and efficiency

- Eliminate double resolveMembership call by filtering banned members post-hoc
- Memoize community context derivation in NoteMoreMenu
- Hoist viewerMember lookup out of render loop in CommunityDetailPage
- Only mount BanConfirmDialog when viewer has ban authority
- Deduplicate NIP-56 report type definitions into canonical source
This commit is contained in:
lemon
2026-04-23 12:59:18 -07:00
parent 3efdcd5a63
commit 9be5650dcd
6 changed files with 57 additions and 53 deletions
+3 -3
View File
@@ -2,10 +2,10 @@ import { useState } from 'react';
import { AlertTriangle, Eye } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { CommunityReport } from '@/lib/communityUtils';
import type { CommunityReport, Nip56ReportType } from '@/lib/communityUtils';
/** Human-readable labels for NIP-56 report types. */
const REPORT_TYPE_LABELS: Record<string, string> = {
/** Lowercase prose labels for content warning summaries. */
const REPORT_TYPE_LABELS: Record<Nip56ReportType, string> = {
nudity: 'nudity',
spam: 'spam',
profanity: 'hateful speech',
+1 -1
View File
@@ -168,6 +168,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
// ── Members ─────────────────────────────────────────────────────────────────
const { data: membership, moderation, memberMap, isLoading: membersLoading } = useCommunityMembers(community);
const viewerMember = user ? memberMap.get(user.pubkey) : undefined;
// Batch-fetch profiles for all members
const allMemberPubkeys = useMemo(
@@ -386,7 +387,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
roleLabel = m.pubkey === event.pubkey ? 'Founder' : 'Moderator';
}
// Determine if the current user can ban this member
const viewerMember = user ? memberMap.get(user.pubkey) : undefined;
const canBanMember = viewerMember
&& m.pubkey !== user?.pubkey
&& viewerMember.rank < m.rank;
+23 -29
View File
@@ -13,21 +13,12 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { toast } from '@/hooks/useToast';
import type { NostrEvent } from '@nostrify/nostrify';
import { REPORT_KIND } from '@/lib/communityUtils';
// ── Report type options (NIP-56) ──────────────────────────────────────────────
const REPORT_TYPES = [
{ value: 'spam', label: 'Spam', description: 'Unsolicited or repetitive content' },
{ value: 'nudity', label: 'Nudity or sexual content', description: 'Depictions of nudity or pornography' },
{ value: 'profanity', label: 'Hateful speech', description: 'Profanity, hateful or abusive speech' },
{ value: 'illegal', label: 'Illegal content', description: 'Content that may be illegal in some jurisdictions' },
{ value: 'impersonation', label: 'Impersonation', description: 'Pretending to be someone else' },
{ value: 'malware', label: 'Malware', description: 'Virus, trojan horse, spyware, or ransomware' },
{ value: 'other', label: 'Other', description: 'Something else not listed above' },
] as const;
type ReportType = typeof REPORT_TYPES[number]['value'];
import {
type Nip56ReportType,
NIP56_REPORT_TYPES,
NIP56_REPORT_TYPE_META,
REPORT_KIND,
} from '@/lib/communityUtils';
// ── Props ─────────────────────────────────────────────────────────────────────
@@ -48,7 +39,7 @@ export function CommunityReportDialog({
}: CommunityReportDialogProps) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const [reportType, setReportType] = useState<ReportType | ''>('');
const [reportType, setReportType] = useState<Nip56ReportType | ''>('');
const [details, setDetails] = useState('');
const canSubmit = reportType !== '' && !isPending;
@@ -87,21 +78,24 @@ export function CommunityReportDialog({
<div className="flex-1 overflow-y-auto min-h-0 -mx-6 px-6">
<RadioGroup
value={reportType}
onValueChange={(v) => setReportType(v as ReportType)}
onValueChange={(v) => setReportType(v as Nip56ReportType)}
className="mt-2 space-y-1"
>
{REPORT_TYPES.map((type) => (
<label
key={type.value}
className="flex items-start gap-3 rounded-lg px-3 py-2.5 cursor-pointer transition-colors hover:bg-secondary/60"
>
<RadioGroupItem value={type.value} className="mt-0.5 shrink-0" />
<div className="min-w-0">
<span className="text-sm font-medium">{type.label}</span>
<p className="text-xs text-muted-foreground mt-0.5">{type.description}</p>
</div>
</label>
))}
{NIP56_REPORT_TYPES.map((type) => {
const meta = NIP56_REPORT_TYPE_META[type];
return (
<label
key={type}
className="flex items-start gap-3 rounded-lg px-3 py-2.5 cursor-pointer transition-colors hover:bg-secondary/60"
>
<RadioGroupItem value={type} className="mt-0.5 shrink-0" />
<div className="min-w-0">
<span className="text-sm font-medium">{meta.label}</span>
<p className="text-xs text-muted-foreground mt-0.5">{meta.description}</p>
</div>
</label>
);
})}
</RadioGroup>
<div className="mt-3 space-y-2 pb-1">
+5 -4
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { nip19 } from 'nostr-tools';
import { useNavigate } from 'react-router-dom';
@@ -218,7 +218,8 @@ export function NoteMoreMenu({ event, open, onOpenChange, communityContext: comm
// Auto-detect community context from React Context if not explicitly provided
const { user } = useCurrentUser();
const communityModCtx = useCommunityModerationContext();
const communityContext = communityContextProp ?? (() => {
const communityContext = useMemo(() => {
if (communityContextProp) return communityContextProp;
if (!communityModCtx || !user) return undefined;
const viewerMember = communityModCtx.memberMap.get(user.pubkey);
if (!viewerMember) return undefined; // Non-member: no community report
@@ -230,7 +231,7 @@ export function NoteMoreMenu({ event, open, onOpenChange, communityContext: comm
communityATag: communityModCtx.communityATag,
canBan,
};
})();
}, [communityContextProp, communityModCtx, user, event.pubkey]);
const { mutate: deleteEvent, isPending: isDeleting } = useDeleteEvent();
@@ -298,7 +299,7 @@ export function NoteMoreMenu({ event, open, onOpenChange, communityContext: comm
<ReportDialog event={event} open={reportOpen} onOpenChange={setReportOpen} />
)}
{communityContext && (
{communityContext?.canBan && (
<>
<BanConfirmDialog
mode="content"
+14 -7
View File
@@ -34,7 +34,7 @@ const EMPTY_MEMBER_MAP = new Map<string, CommunityMember>();
/**
* Fetch and resolve the full membership tree and moderation state for a community.
*
* Queries badge awards (kind 8), reports (kind 1984), and deletions (kind 5),
* 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) {
@@ -72,20 +72,27 @@ export function useCommunityMembers(community: ParsedCommunity | null | undefine
),
]);
// Step 1-2: Resolve membership WITHOUT moderation (needed for authority checks)
const preModerationMembership = resolveMembership(community, awards);
// Step 1-2: Resolve full membership (needed for authority checks)
const fullMembership = resolveMembership(community, awards);
// Build member lookup map for authority checks
const memberMap = new Map<string, CommunityMember>();
for (const m of preModerationMembership.members) {
for (const m of fullMembership.members) {
memberMap.set(m.pubkey, m);
}
// Step 3: Resolve moderation using the pre-moderation member map
// Step 3: Resolve moderation using the member map
const moderation = resolveCommunityModeration(reports, memberMap);
// Step 4: Re-resolve membership WITH moderation overlay (removes banned members)
const membership = resolveMembership(community, awards, moderation);
// Step 4: Apply moderation overlay — filter banned members from the
// already-computed membership rather than re-running chain validation.
const filteredMembers = fullMembership.members.filter(
(m) => !moderation.bannedPubkeys.has(m.pubkey),
);
const membership: CommunityMembership = {
members: filteredMembers,
totalCount: filteredMembers.length,
};
return { membership, moderation, memberMap };
},
+11 -9
View File
@@ -37,6 +37,17 @@ export const NIP56_REPORT_TYPES = [
export type Nip56ReportType = typeof NIP56_REPORT_TYPES[number];
/** Human-readable metadata for each NIP-56 report type. */
export const NIP56_REPORT_TYPE_META: Record<Nip56ReportType, { label: string; description: string }> = {
spam: { label: 'Spam', description: 'Unsolicited or repetitive content' },
nudity: { label: 'Nudity or sexual content', description: 'Depictions of nudity or pornography' },
profanity: { label: 'Hateful speech', description: 'Profanity, hateful or abusive speech' },
illegal: { label: 'Illegal content', description: 'Content that may be illegal in some jurisdictions' },
impersonation: { label: 'Impersonation', description: 'Pretending to be someone else' },
malware: { label: 'Malware', description: 'Virus, trojan horse, spyware, or ransomware' },
other: { label: 'Other', description: 'Something else not listed above' },
};
// ── Rank tier metadata ────────────────────────────────────────────────────────
export interface RankTier {
@@ -315,12 +326,10 @@ export function resolveCommunityModeration(
* 1. Seed rank 0 from the community definition (founder + moderators).
* 2. Iteratively validate badge awards — awarder must be a validated
* member with rank strictly less than the awarded badge's rank.
* 3. Apply moderation overlay — remove banned members from the result.
*/
export function resolveMembership(
community: ParsedCommunity,
awardEvents: NostrEvent[],
moderation?: CommunityModeration,
): CommunityMembership {
// Build badge-to-rank lookup
const badgeToRank = new Map<string, number>();
@@ -393,13 +402,6 @@ export function resolveMembership(
}
}
// Step 3: Apply moderation overlay — remove banned members
if (moderation) {
for (const bannedPk of moderation.bannedPubkeys) {
validated.delete(bannedPk);
}
}
const members = Array.from(validated.values());
members.sort((a, b) => a.rank - b.rank);