diff --git a/src/App.tsx b/src/App.tsx index 37d2a765..88ed7d9a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -64,6 +64,7 @@ const defaultConfig: AppConfig = { defaultZapComment: 'Zapped with Mew!', faviconProvider: 'https://favicon.shakespeare.diy/?url={href}', corsProxy: 'https://proxy.shakespeare.diy/?url={href}', + contentWarningPolicy: 'blur', }; export function App() { diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index f1cbdfa6..073af76a 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -55,6 +55,7 @@ const AppConfigSchema = z.object({ defaultZapComment: z.string(), faviconProvider: z.string(), corsProxy: z.string(), + contentWarningPolicy: z.enum(['blur', 'hide', 'show']), }) satisfies z.ZodType; export function AppProvider(props: AppProviderProps) { diff --git a/src/components/ContentSettings.tsx b/src/components/ContentSettings.tsx index d8548d8e..c7fcfd6b 100644 --- a/src/components/ContentSettings.tsx +++ b/src/components/ContentSettings.tsx @@ -11,6 +11,7 @@ export function ContentSettings() { const [otherStuffOpen, setOtherStuffOpen] = useState(true); const [feedTabsOpen, setFeedTabsOpen] = useState(false); const [mutesOpen, setMutesOpen] = useState(false); + const [sensitiveOpen, setSensitiveOpen] = useState(false); return (
@@ -130,7 +131,29 @@ export function ContentSettings() {
- {/* TODO: Sensitive Content Section */} + {/* Sensitive Content Section */} +
+ + + + + +
+ +
+
+
+
); } @@ -517,6 +540,85 @@ function FeedTabsSection() { ); } +// Sensitive content settings section +import { useAppContext } from '@/hooks/useAppContext'; +import type { ContentWarningPolicy } from '@/contexts/AppContext'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { ShieldAlert } from 'lucide-react'; + +const CW_POLICY_OPTIONS: { value: ContentWarningPolicy; label: string; description: string }[] = [ + { + value: 'blur', + label: 'Blur until revealed', + description: 'Content is hidden behind a warning. Media is not loaded until you choose to view it.', + }, + { + value: 'hide', + label: 'Hide completely', + description: 'Posts with content warnings are removed from your feed entirely.', + }, + { + value: 'show', + label: 'Always show', + description: 'Ignore content warnings and display everything normally.', + }, +]; + +function SensitiveContentSection() { + const { config, updateConfig } = useAppContext(); + const { updateSettings } = useEncryptedSettings(); + const { user } = useCurrentUser(); + + const handlePolicyChange = async (value: string) => { + const policy = value as ContentWarningPolicy; + updateConfig((current) => ({ ...current, contentWarningPolicy: policy })); + if (user) { + await updateSettings.mutateAsync({ contentWarningPolicy: policy }); + } + }; + + return ( +
+ {/* Intro */} +
+
+ +
+
+

Content Warnings

+

+ Some posts are tagged with content warnings (NIP-36) by their authors. This can include NSFW material, spoilers, or other sensitive content. Choose how you want to handle them. +

+
+
+ + {/* Policy radio group */} +
+ + {CW_POLICY_OPTIONS.map((option) => ( + + ))} + +
+
+ ); +} + // Mute settings internals (without the intro/image) import { Trash2, Plus, UserX, Hash, MessageSquareOff } from 'lucide-react'; import { Skeleton } from '@/components/ui/skeleton'; diff --git a/src/components/ContentWarningGuard.tsx b/src/components/ContentWarningGuard.tsx new file mode 100644 index 00000000..34e3c89c --- /dev/null +++ b/src/components/ContentWarningGuard.tsx @@ -0,0 +1,114 @@ +import { useState } from 'react'; +import { ShieldAlert, Eye } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useAppContext } from '@/hooks/useAppContext'; +import { cn } from '@/lib/utils'; +import type { NostrEvent } from '@nostrify/nostrify'; + +/** + * Extracts the content-warning reason from an event's tags (NIP-36). + * Returns the reason string, or an empty string if the tag is present with no reason, + * or undefined if there is no content-warning tag. + */ +export function getContentWarning(event: NostrEvent): string | undefined { + const tag = event.tags.find(([name]) => name === 'content-warning'); + if (!tag) return undefined; + return tag[1] ?? ''; +} + +interface ContentWarningGuardProps { + /** The Nostr event to check for content-warning tags. */ + event: NostrEvent; + /** Content that should only render when the warning is dismissed. */ + children: React.ReactNode; + /** Optional class name for the warning overlay container. */ + className?: string; +} + +/** + * Guards children behind a content-warning overlay based on the user's + * contentWarningPolicy setting. + * + * - "blur": Shows a warning overlay. Children are **not mounted** (and therefore + * media is never fetched) until the user explicitly reveals. + * - "hide": Returns null — the entire post should be hidden by the parent. + * - "show": Renders children immediately with no overlay. + * + * If the event has no content-warning tag, children render normally regardless + * of the policy setting. + */ +export function ContentWarningGuard({ event, children, className }: ContentWarningGuardProps) { + const { config } = useAppContext(); + const [revealed, setRevealed] = useState(false); + + const reason = getContentWarning(event); + + // No content-warning tag — render normally + if (reason === undefined) { + return <>{children}; + } + + const policy = config.contentWarningPolicy; + + // Policy: always show — ignore the warning + if (policy === 'show') { + return <>{children}; + } + + // Policy: hide — parent should filter this out, but as a fallback return null + if (policy === 'hide') { + return null; + } + + // Policy: blur (default) — show overlay until revealed + if (revealed) { + return <>{children}; + } + + return ( +
+ {/* Grey blur filler — mimics a content area so the card doesn't look empty */} +
+ {/* Fake content lines */} +
+
+
+
+
+ {/* Fake image block */} +
+ + {/* Centered overlay content */} +
+
+ +
+
+

Content Warning

+ {reason ? ( +

+ “{reason}” +

+ ) : ( +

+ The author flagged this post as sensitive. +

+ )} +
+ +
+
+
+ ); +} diff --git a/src/components/EmbeddedNote.tsx b/src/components/EmbeddedNote.tsx index e1f1e406..17a456e3 100644 --- a/src/components/EmbeddedNote.tsx +++ b/src/components/EmbeddedNote.tsx @@ -11,6 +11,7 @@ import { genUserName } from '@/lib/genUserName'; import { getProfileUrl } from '@/lib/profileUrl'; import { timeAgo } from '@/lib/timeAgo'; import { cn } from '@/lib/utils'; +import { useAppContext } from '@/hooks/useAppContext'; /** Bech32 charset used by NIP-19 identifiers. */ const B32 = '023456789acdefghjklmnpqrstuvwxyz'; @@ -88,8 +89,10 @@ function EmbeddedNoteCard({ event: { id: string; pubkey: string; content: string; created_at: number; tags: string[][] }; className?: string; }) { + const { config } = useAppContext(); const navigate = useNavigate(); const author = useAuthor(event.pubkey); + const metadata = author.data?.metadata; const displayName = metadata?.name || genUserName(event.pubkey); const profileUrl = useMemo(() => getProfileUrl(event.pubkey, metadata), [event.pubkey, metadata]); @@ -120,6 +123,15 @@ function EmbeddedNoteCard({ return match?.[0] ?? null; }, [event.content]); + // NIP-36 content-warning check + const cwTag = event.tags.find(([name]) => name === 'content-warning'); + const hasCW = !!cwTag; + + // If policy is "hide", don't render the embedded note at all + if (hasCW && config.contentWarningPolicy === 'hide') { + return null; + } + return (
- {/* Optional image thumbnail */} - {firstImage && ( + {/* Optional image thumbnail — skip when content-warning is blurred */} + {firstImage && !(hasCW && config.contentWarningPolicy === 'blur') && (
- {/* Text preview with inline mentions */} - {truncatedContent && ( + {/* Content warning notice or text preview */} + {hasCW && config.contentWarningPolicy === 'blur' ? ( +

+ Content warning{cwTag?.[1] ? <>{' '}“{cwTag[1]}” : ''} +

+ ) : truncatedContent ? ( - )} + ) : null}
); diff --git a/src/components/NostrSync.tsx b/src/components/NostrSync.tsx index 3a667e16..a4889b98 100644 --- a/src/components/NostrSync.tsx +++ b/src/components/NostrSync.tsx @@ -112,6 +112,11 @@ export function NostrSync() { }; } + // Sync contentWarningPolicy if available + if (encryptedSettings.contentWarningPolicy && encryptedSettings.contentWarningPolicy !== current.contentWarningPolicy) { + updates.contentWarningPolicy = encryptedSettings.contentWarningPolicy; + } + return updates; }); }, [user, encryptedSettings, settingsLoading, updateConfig, recentlyWritten]); diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index be77393f..e7dacca8 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -34,6 +34,8 @@ import type { NostrEvent } from '@nostrify/nostrify'; import { NoteMoreMenu } from '@/components/NoteMoreMenu'; import { ReplyComposeModal } from '@/components/ReplyComposeModal'; import { ZapDialog } from '@/components/ZapDialog'; +import { ContentWarningGuard, getContentWarning } from '@/components/ContentWarningGuard'; +import { useAppContext } from '@/hooks/useAppContext'; interface NoteCardProps { event: NostrEvent; @@ -131,8 +133,10 @@ function encodeEventId(event: NostrEvent): string { export function NoteCard({ event, className, repostedBy, compact }: NoteCardProps) { const navigate = useNavigate(); + const { config } = useAppContext(); const { user } = useCurrentUser(); const author = useAuthor(event.pubkey); + const metadata = author.data?.metadata; const displayName = getDisplayName(metadata, event.pubkey); const nip05 = metadata?.nip05; @@ -202,6 +206,11 @@ export function NoteCard({ event, className, repostedBy, compact }: NoteCardProp const vineTitle = isVine ? getTag(event.tags, 'title') : undefined; const hashtags = isVine ? event.tags.filter(([n]) => n === 't').map(([, v]) => v) : []; + // NIP-36: If the event has a content-warning and the policy is "hide", skip rendering entirely + if (getContentWarning(event) !== undefined && config.contentWarningPolicy === 'hide') { + return null; + } + return (
)} - {/* Content — kind-based dispatch */} - {isVine ? ( - <> - {vineTitle &&

{vineTitle}

} - - - ) : isPoll ? ( - - ) : isGeocache ? ( - - ) : isFoundLog ? ( - - ) : isColor ? ( - - ) : isFollowPack ? ( - - ) : ( - <> -
- -
- - - )} + {/* Content — kind-based dispatch, guarded by NIP-36 content-warning */} + + {isVine ? ( + <> + {vineTitle &&

{vineTitle}

} + + + ) : isPoll ? ( + + ) : isGeocache ? ( + + ) : isFoundLog ? ( + + ) : isColor ? ( + + ) : isFollowPack ? ( + + ) : ( + <> +
+ +
+ + + )} +
{/* Action buttons — hidden in compact/embed mode */} {!compact && ( diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index 7b5297fa..09e63202 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -2,6 +2,17 @@ import { createContext } from "react"; export type Theme = "dark" | "light" | "black" | "pink"; +/** + * How to handle events with a NIP-36 content-warning tag. + * - "blur": Show a warning overlay; media is not loaded until the user reveals. + * - "hide": Remove the event from view entirely. + * - "show": Ignore the content-warning tag and display normally. + */ +export type ContentWarningPolicy = "blur" | "hide" | "show"; + +/** How to handle events with a NIP-36 content-warning tag. */ +export type NsfwPolicy = "blur" | "hide" | "show"; + export interface RelayMetadata { /** List of relays with read/write permissions */ relays: { url: string; read: boolean; write: boolean }[]; @@ -64,6 +75,8 @@ export interface AppConfig { faviconProvider: string; /** CORS proxy URI template. Use {href} as placeholder for the target URL (URL-encoded). */ corsProxy: string; + /** How to handle NIP-36 content-warning events (blur, hide, or show). Default: "blur". */ + contentWarningPolicy: ContentWarningPolicy; } export interface AppContextType { diff --git a/src/hooks/useEncryptedSettings.ts b/src/hooks/useEncryptedSettings.ts index 778b363b..eb1f73a7 100644 --- a/src/hooks/useEncryptedSettings.ts +++ b/src/hooks/useEncryptedSettings.ts @@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import type { NostrFilter } from '@nostrify/nostrify'; import { useCurrentUser } from './useCurrentUser'; -import type { Theme, FeedSettings } from '@/contexts/AppContext'; +import type { Theme, FeedSettings, ContentWarningPolicy } from '@/contexts/AppContext'; import type { ContentFilter } from './useContentFilters'; const SETTINGS_D_TAG = 'mew-metadata'; @@ -28,6 +28,8 @@ export interface EncryptedSettings { feedSettings?: FeedSettings; /** Advanced content filters */ contentFilters?: ContentFilter[]; + /** How to handle NIP-36 content-warning events */ + contentWarningPolicy?: ContentWarningPolicy; /** Timestamp of last viewed notification (Unix timestamp in seconds) */ notificationsCursor?: number; /** Last sync timestamp */ diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index 3b68fd9a..d0a2de55 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -49,6 +49,7 @@ import { timeAgo } from '@/lib/timeAgo'; import { Nip05Badge } from '@/components/Nip05Badge'; import { ProfileHoverCard } from '@/components/ProfileHoverCard'; import { getProfileUrl } from '@/lib/profileUrl'; +import { ContentWarningGuard } from '@/components/ContentWarningGuard'; interface PostDetailPageProps { @@ -704,27 +705,29 @@ function PostDetailContent({ event }: { event: NostrEvent }) { )}
- {/* Post content — kind-based dispatch (same as NoteCard) */} - {isVine || isPoll || isGeocache || isFoundLog || isColor || isFollowPack ? ( - <> - {isVine && } - {isPoll && } - {isGeocache && } - {isFoundLog && } - {isColor && } - {isFollowPack && } - - ) : ( - <> -
- -
- {videos.map((url, i) => ( - - ))} - - - )} + {/* Post content — kind-based dispatch, guarded by NIP-36 content-warning */} + + {isVine || isPoll || isGeocache || isFoundLog || isColor || isFollowPack ? ( + <> + {isVine && } + {isPoll && } + {isGeocache && } + {isFoundLog && } + {isColor && } + {isFollowPack && } + + ) : ( + <> +
+ +
+ {videos.map((url, i) => ( + + ))} + + + )} +
{/* Stats row: "2 Reposts 1 👍" left, "Feb 16, 2026, 6:44 PM" right — Ditto style */} {hasStats && ( @@ -997,24 +1000,26 @@ function ParentNote({ eventId }: { eventId: string }) { )}
- {/* Note text */} -
- -
- - {/* Videos */} - {videos.map((url, i) => ( -
- + {/* Note text + media, guarded by NIP-36 content-warning */} + +
+
- ))} - {/* Images */} - {images.length > 0 && ( -
- -
- )} + {/* Videos */} + {videos.map((url, i) => ( +
+ +
+ ))} + + {/* Images */} + {images.length > 0 && ( +
+ +
+ )} +
diff --git a/src/test/TestApp.tsx b/src/test/TestApp.tsx index 69437f0d..dcc7f6b7 100644 --- a/src/test/TestApp.tsx +++ b/src/test/TestApp.tsx @@ -53,6 +53,7 @@ export function TestApp({ children }: TestAppProps) { defaultZapComment: 'Zapped with Mew!', faviconProvider: 'https://favicon.shakespeare.diy/?url={href}', corsProxy: 'https://proxy.shakespeare.diy/?url={href}', + contentWarningPolicy: 'blur', }; return (