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:
@@ -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}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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." />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user