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:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user