Enforce report pubkey match and add members-only filter toggle

Two NIP-alignment fixes:

Gap 1 — Report warnings now require `p` match (correctness).

Previously `CommunityContentWarning` looked up reports by event id only,
so any community member could publish a kind 1984 pairing a victim
event's id with their own pubkey on the `p` tag to force a warning
overlay onto an arbitrary event. Added `getApplicableReports` in
communityUtils mirroring `hasApplicableContentBan`, and use it to
require `report.targetPubkey === event.pubkey` before the warning
renders. Matches NIP.md §Reports — Content Warnings: "report warnings
MUST only attach to content when the target event's id matches the
report's `e` tag and the target event's pubkey matches the report's
`p` tag."

Gap 2 — Members-only filter toggle.

The NIP recommends canonical community feeds discard non-member
content by default. Added a shield-icon toggle that controls this as
a presentation-layer filter, defaulting on. When active, community
feeds (Activities feed, per-community Comments tab, and any future
community-scoped content surfaces) only show events authored by
chain-validated members. When off, everything scoped to the
community is shown regardless of authorship.

- `useMembersOnlyFilter` — localStorage-backed hook with cross-tab
  sync; one preference shared across all community surfaces.
- `MembersOnlyToggle` — shield / shield-off icon button with tooltip
  explaining current state.
- Filtering is applied post-query in the consumer pages, so toggling
  is instant and doesn't invalidate the query cache.
- Community definition events (kind 34550) are never filtered — they
  represent the community itself, not user-generated content.
- Toggle placement: in `CommunitiesPage` header (scopes the global
  Activities feed); in `CommunityDetailPage` alongside the tabs
  (scopes every content feed in that community, now and future).
- Empty-state copy hints at the filter when a list is empty only
  because of it.
This commit is contained in:
lemon
2026-04-27 00:11:44 -07:00
parent 8923aa87e2
commit e21ee2e4fc
7 changed files with 191 additions and 32 deletions
+12 -7
View File
@@ -1,9 +1,10 @@
import { useState } from 'react';
import { AlertTriangle, Eye } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { Button } from '@/components/ui/button';
import { useCommunityModerationContext } from '@/contexts/CommunityModerationContext';
import { cn } from '@/lib/utils';
import type { Nip56ReportType } from '@/lib/communityUtils';
import { getApplicableReports, type Nip56ReportType } from '@/lib/communityUtils';
/** Lowercase prose labels for content warning summaries. */
const REPORT_TYPE_LABELS: Record<Nip56ReportType, string> = {
@@ -17,8 +18,8 @@ const REPORT_TYPE_LABELS: Record<Nip56ReportType, string> = {
};
interface CommunityContentWarningProps {
/** The event being rendered. Used to look up reports in the community context. */
eventId: string;
/** The event being rendered. */
event: NostrEvent;
/** The content to guard behind the warning when the event has reports. */
children: React.ReactNode;
/** Optional class name for the wrapper. */
@@ -32,16 +33,20 @@ interface CommunityContentWarningProps {
* care about the community system — rendering this wrapper is a no-op when
* there's no community context in the tree.
*
* Only reports whose `p` tag matches the event's actual author are considered
* — this mirrors the id+pubkey match requirement in the NIP and prevents a
* report from hijacking the warning overlay onto an unrelated event.
*
* Children are **not mounted** until the user explicitly reveals, so media and
* nested queries are deferred for reported content.
*/
export function CommunityContentWarning({ eventId, children, className }: CommunityContentWarningProps) {
export function CommunityContentWarning({ event, children, className }: CommunityContentWarningProps) {
const modCtx = useCommunityModerationContext();
const reports = modCtx?.moderation.reportsByEventId.get(eventId);
const reports = modCtx ? getApplicableReports(event, modCtx.moderation) : [];
const [revealed, setRevealed] = useState(false);
// No community context or no reports → render children transparently.
if (!reports || reports.length === 0 || revealed) {
// No community context or no applicable reports → render children transparently.
if (reports.length === 0 || revealed) {
return <>{children}</>;
}
+42 -20
View File
@@ -19,12 +19,14 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { BanConfirmDialog } from '@/components/BanConfirmDialog';
import { ComposeBox } from '@/components/ComposeBox';
import { MembersOnlyToggle } from '@/components/MembersOnlyToggle';
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
import { useAuthor } from '@/hooks/useAuthor';
import { useAuthors } from '@/hooks/useAuthors';
import { useComments } from '@/hooks/useComments';
import { useCommunityMembers } from '@/hooks/useCommunityMembers';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useMembersOnlyFilter } from '@/hooks/useMembersOnlyFilter';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { CommunityModerationContext } from '@/contexts/CommunityModerationContext';
@@ -187,14 +189,21 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
// ── Comments (NIP-22 on the community event) ───────────────────────────────
const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500);
const { membersOnly } = useMembersOnlyFilter();
const replyTree = useMemo((): ReplyNode[] => {
if (!commentsData) return [];
const topLevel = commentsData.topLevelComments ?? [];
// Filter: omit banned events and posts by banned members
const applyModeration = (events: NostrEvent[]): NostrEvent[] =>
applyCommunityModerationToEvents(events, moderation);
// Filter: omit banned events and posts by banned members, then optionally
// restrict to chain-validated members when the "members only" toggle is
// active. The member filter is a presentation-layer choice — the NIP
// recommends it as the canonical-feed default, but users may opt out.
const applyModeration = (events: NostrEvent[]): NostrEvent[] => {
const moderated = applyCommunityModerationToEvents(events, moderation);
if (!membersOnly) return moderated;
return moderated.filter((ev) => rankMap.has(ev.pubkey));
};
const buildNode = (ev: NostrEvent): ReplyNode => {
const allChildren = applyModeration(commentsData.getDirectReplies(ev.id) ?? []);
@@ -215,7 +224,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
return applyModeration([...topLevel])
.sort((a, b) => a.created_at - b.created_at)
.map((r) => buildNode(r));
}, [commentsData, moderation]);
}, [commentsData, moderation, membersOnly, rankMap]);
// ── Share handler ───────────────────────────────────────────────────────────
const handleShare = useCallback(async () => {
@@ -290,22 +299,31 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
{/* ── Tabs ── */}
<CommunityModerationContext.Provider value={communityATag ? { communityATag, moderation, rankMap } : null}>
<Tabs defaultValue="members" className="-mx-5">
<TabsList className="w-full rounded-none border-b border-border bg-transparent p-0 h-auto">
<TabsTrigger
value="members"
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-3 pt-2"
>
<Users className="size-4 mr-1.5" />
Members
</TabsTrigger>
<TabsTrigger
value="comments"
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-3 pt-2"
>
<MessageCircle className="size-4 mr-1.5" />
Comments
</TabsTrigger>
</TabsList>
{/* The TabsList stays flex so tabs share width, and the toggle
sits to the right of the tabs. The toggle filters all
content feeds within this community (currently only
Comments, but scoped that way so future feeds inherit). */}
<div className="flex items-stretch border-b border-border">
<TabsList className="flex-1 rounded-none bg-transparent p-0 h-auto">
<TabsTrigger
value="members"
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-3 pt-2"
>
<Users className="size-4 mr-1.5" />
Members
</TabsTrigger>
<TabsTrigger
value="comments"
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-3 pt-2"
>
<MessageCircle className="size-4 mr-1.5" />
Comments
</TabsTrigger>
</TabsList>
<div className="flex items-center pr-2 shrink-0">
<MembersOnlyToggle />
</div>
</div>
{/* ── Members tab ── */}
<TabsContent value="members" className="mt-0">
@@ -366,6 +384,10 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
</div>
) : replyTree.length > 0 ? (
<ThreadedReplyList roots={replyTree} />
) : membersOnly && commentsData && (commentsData.topLevelComments?.length ?? 0) > 0 ? (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
No comments from community members yet. Toggle the shield icon to see all comments.
</div>
) : (
<div className="py-12 text-center text-muted-foreground text-sm">
No comments yet. Be the first to comment!
+59
View File
@@ -0,0 +1,59 @@
import { Shield, ShieldOff } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useMembersOnlyFilter } from '@/hooks/useMembersOnlyFilter';
import { cn } from '@/lib/utils';
interface MembersOnlyToggleProps {
/** Additional classes for the trigger button. */
className?: string;
}
/**
* Shield-icon toggle that controls the "members only" filter for community
* surfaces. When active (default), community feeds only show content authored
* by chain-validated members — matching the NIP's canonical-author
* recommendation. When inactive, the feed shows every event scoped to the
* community regardless of author.
*
* The preference is persisted in localStorage via `useMembersOnlyFilter` and
* is global across community surfaces (Activities feed, per-community
* Comments tab, etc.).
*/
export function MembersOnlyToggle({ className }: MembersOnlyToggleProps) {
const { membersOnly, toggle } = useMembersOnlyFilter();
const label = membersOnly ? 'Showing members only' : 'Showing everyone';
const hint = membersOnly
? 'Click to show posts from anyone scoped to this community.'
: 'Click to limit posts to validated community members.';
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={toggle}
aria-pressed={membersOnly}
aria-label={label}
className={cn(
'p-2 rounded-full transition-colors',
membersOnly
? 'text-primary hover:bg-primary/10'
: 'text-muted-foreground hover:bg-secondary',
className,
)}
>
{membersOnly
? <Shield className="size-5" />
: <ShieldOff className="size-5" />}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[220px] text-center">
<p className="text-xs font-medium">{label}</p>
<p className="text-xs text-muted-foreground mt-0.5">{hint}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
+1 -1
View File
@@ -556,7 +556,7 @@ export const NoteCard = memo(function NoteCard({
// Wrapped in `CommunityContentWarning`, which subscribes to the community
// moderation context internally and is a no-op outside community surfaces.
const contentBlock = (
<CommunityContentWarning eventId={event.id}>
<CommunityContentWarning event={event}>
{/* Reply context (kind 1) or comment context (kind 1111) — shown above content */}
{isComment && <CommentContext event={event} />}
{isReply && (
+29
View File
@@ -0,0 +1,29 @@
import { useCallback } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
/**
* LocalStorage key for the "members only" filter toggle.
* Shared across all community surfaces so the preference is global.
*/
const STORAGE_KEY = 'community:members-only';
/**
* Controls whether community views filter content down to posts authored by
* chain-validated members, or show everything scoped to the community.
*
* Defaults to `true` (members-only), which aligns with the NIP's "canonical
* community feeds SHOULD discard non-member content by default" guidance
* (see NIP.md §Community-Scoped Content). Users can opt out per their
* preference via a shield-icon toggle in the UI.
*
* The preference is persisted in localStorage and synchronised across tabs.
*/
export function useMembersOnlyFilter() {
const [membersOnly, setMembersOnly] = useLocalStorage<boolean>(STORAGE_KEY, true);
const toggle = useCallback(() => {
setMembersOnly((prev) => !prev);
}, [setMembersOnly]);
return { membersOnly, setMembersOnly, toggle };
}
+19
View File
@@ -274,6 +274,25 @@ export function hasApplicableContentBan(
return candidates.some((ban) => ban.targetPubkey === event.pubkey);
}
/**
* Returns the list of reports that apply to this event, or an empty array.
*
* Like content bans, a report's `p` tag is untrusted until it matches the
* target event's actual author. Without this pubkey match, any member
* could publish a kind 1984 pairing a victim event's ID with the
* reporter's own pubkey, forcing a content warning on an arbitrary event.
* This mirrors the id+pubkey match requirement in the NIP (see NIP.md
* §Classification Summary and §Reports — Content Warnings).
*/
export function getApplicableReports(
event: NostrEvent,
moderation: CommunityModeration,
): CommunityReport[] {
const candidates = moderation.reportsByEventId.get(event.id);
if (!candidates) return [];
return candidates.filter((report) => report.targetPubkey === event.pubkey);
}
/**
* Returns true when a single event survives community moderation
* (not banned by author or content ban). Use for single-event checks;
+29 -4
View File
@@ -6,6 +6,7 @@ import { Users } from 'lucide-react';
import { CommunityCard } from '@/components/CommunityCard';
import { FeedEmptyState } from '@/components/FeedEmptyState';
import { LoginArea } from '@/components/auth/LoginArea';
import { MembersOnlyToggle } from '@/components/MembersOnlyToggle';
import { NoteCard } from '@/components/NoteCard';
import { PageHeader } from '@/components/PageHeader';
import { PullToRefresh } from '@/components/PullToRefresh';
@@ -13,12 +14,13 @@ import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { Skeleton } from '@/components/ui/skeleton';
import { CommunityModerationContext, type CommunityModerationContextValue } from '@/contexts/CommunityModerationContext';
import { EMPTY_MODERATION } from '@/lib/communityUtils';
import { COMMUNITY_DEFINITION_KIND, EMPTY_MODERATION } from '@/lib/communityUtils';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { useAppContext } from '@/hooks/useAppContext';
import { useCommunityActivityFeed } from '@/hooks/useCommunityActivityFeed';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFeedTab } from '@/hooks/useFeedTab';
import { useMembersOnlyFilter } from '@/hooks/useMembersOnlyFilter';
import { useMyCommunities } from '@/hooks/useMyCommunities';
// ─── Types ─────────────────────────────────────────────────────────────────────
@@ -91,7 +93,9 @@ export function CommunitiesPage() {
return (
<main className="pb-16 sidebar:pb-0">
<PageHeader title="Communities" icon={<Users className="size-5" />} />
<PageHeader title="Communities" icon={<Users className="size-5" />}>
{user && activeTab === 'activities' && <MembersOnlyToggle />}
</PageHeader>
{/* Activities / My Communities tabs */}
{user && (
@@ -195,6 +199,7 @@ function MyCommunitiesContent() {
function ActivitiesTab({ onRefresh }: { onRefresh: () => Promise<void> }) {
const { user } = useCurrentUser();
const { data: activityEvents, isLoading, moderationByATag, rankMapByATag } = useCommunityActivityFeed();
const { membersOnly } = useMembersOnlyFilter();
// Build per-community context values for NoteMoreMenu moderation actions.
// Keyed by community A tag — each NoteCard is wrapped in its own provider.
@@ -207,6 +212,24 @@ function ActivitiesTab({ onRefresh }: { onRefresh: () => Promise<void> }) {
return map;
}, [moderationByATag, rankMapByATag]);
// Apply the members-only presentation filter. Community definitions
// (kind 34550) are never filtered — they represent the community itself,
// not user-generated content. Only community-scoped content (kind 1111
// and future kinds) is filtered to authored-by-member when the toggle
// is active, matching the NIP's canonical-author guidance.
const displayedEvents = useMemo(() => {
if (!activityEvents) return activityEvents;
if (!membersOnly) return activityEvents;
return activityEvents.filter((event) => {
if (event.kind === COMMUNITY_DEFINITION_KIND) return true;
const aTag = event.tags.find(([n]) => n === 'A')?.[1];
if (!aTag) return true; // No community scope — pass through
const rankMap = rankMapByATag.get(aTag);
if (!rankMap) return true; // Moderation data not resolved — avoid hiding
return rankMap.has(event.pubkey);
});
}, [activityEvents, membersOnly, rankMapByATag]);
if (!user) {
return (
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
@@ -232,9 +255,9 @@ function ActivitiesTab({ onRefresh }: { onRefresh: () => Promise<void> }) {
<NoteCardSkeleton key={i} />
))}
</div>
) : activityEvents && activityEvents.length > 0 ? (
) : displayedEvents && displayedEvents.length > 0 ? (
<div>
{activityEvents.map((event) => {
{displayedEvents.map((event) => {
const aTag = event.tags.find(([n]) => n === 'A')?.[1];
const ctx = aTag ? contextByATag.get(aTag) ?? null : null;
return (
@@ -244,6 +267,8 @@ function ActivitiesTab({ onRefresh }: { onRefresh: () => Promise<void> }) {
);
})}
</div>
) : membersOnly && activityEvents && activityEvents.length > 0 ? (
<FeedEmptyState message="No activity from members of your communities yet. Toggle the shield icon to see all community activity." />
) : (
<FeedEmptyState message="No activity from your communities yet." />
)}