diff --git a/src/components/CommunityContentWarning.tsx b/src/components/CommunityContentWarning.tsx index 2fc825a2..26a43df3 100644 --- a/src/components/CommunityContentWarning.tsx +++ b/src/components/CommunityContentWarning.tsx @@ -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 = { @@ -17,8 +18,8 @@ const REPORT_TYPE_LABELS: Record = { }; 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}; } diff --git a/src/components/CommunityDetailPage.tsx b/src/components/CommunityDetailPage.tsx index 43c3a5fc..54b13132 100644 --- a/src/components/CommunityDetailPage.tsx +++ b/src/components/CommunityDetailPage.tsx @@ -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 ── */} - - - - Members - - - - Comments - - + {/* 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). */} +
+ + + + Members + + + + Comments + + +
+ +
+
{/* ── Members tab ── */} @@ -366,6 +384,10 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { ) : replyTree.length > 0 ? ( + ) : membersOnly && commentsData && (commentsData.topLevelComments?.length ?? 0) > 0 ? ( +
+ No comments from community members yet. Toggle the shield icon to see all comments. +
) : (
No comments yet. Be the first to comment! diff --git a/src/components/MembersOnlyToggle.tsx b/src/components/MembersOnlyToggle.tsx new file mode 100644 index 00000000..d27aa9e3 --- /dev/null +++ b/src/components/MembersOnlyToggle.tsx @@ -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 ( + + + + + + +

{label}

+

{hint}

+
+
+
+ ); +} diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index 1f79b6a3..becf0cde 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -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 = ( - + {/* Reply context (kind 1) or comment context (kind 1111) — shown above content */} {isComment && } {isReply && ( diff --git a/src/hooks/useMembersOnlyFilter.ts b/src/hooks/useMembersOnlyFilter.ts new file mode 100644 index 00000000..49628681 --- /dev/null +++ b/src/hooks/useMembersOnlyFilter.ts @@ -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(STORAGE_KEY, true); + + const toggle = useCallback(() => { + setMembersOnly((prev) => !prev); + }, [setMembersOnly]); + + return { membersOnly, setMembersOnly, toggle }; +} diff --git a/src/lib/communityUtils.ts b/src/lib/communityUtils.ts index bc485701..80ece536 100644 --- a/src/lib/communityUtils.ts +++ b/src/lib/communityUtils.ts @@ -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; diff --git a/src/pages/CommunitiesPage.tsx b/src/pages/CommunitiesPage.tsx index 1dda41f4..777db6e8 100644 --- a/src/pages/CommunitiesPage.tsx +++ b/src/pages/CommunitiesPage.tsx @@ -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 (
- } /> + }> + {user && activeTab === 'activities' && } + {/* Activities / My Communities tabs */} {user && ( @@ -195,6 +199,7 @@ function MyCommunitiesContent() { function ActivitiesTab({ onRefresh }: { onRefresh: () => Promise }) { 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 }) { 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 (
@@ -232,9 +255,9 @@ function ActivitiesTab({ onRefresh }: { onRefresh: () => Promise }) { ))}
- ) : activityEvents && activityEvents.length > 0 ? ( + ) : displayedEvents && displayedEvents.length > 0 ? (
- {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 }) { ); })}
+ ) : membersOnly && activityEvents && activityEvents.length > 0 ? ( + ) : ( )}