Add NIP-36 content warning support with blur/hide/show settings

Respect content-warning tags on events. Users can choose how to handle
them in Settings > Content > Sensitive Content:

- Blur (default): Shows a grey placeholder overlay with the warning
  reason in quotes. Media is not loaded until the user clicks reveal.
- Hide: Removes content-warned posts from the feed entirely.
- Show: Ignores content warnings and displays everything normally.

The setting syncs across devices via encrypted NIP-78 settings.
This commit is contained in:
Chad Curtis
2026-02-20 16:45:33 -06:00
parent b0347e213b
commit eb483469bb
11 changed files with 339 additions and 68 deletions
+1
View File
@@ -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() {
+1
View File
@@ -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<AppConfig>;
export function AppProvider(props: AppProviderProps) {
+103 -1
View File
@@ -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 (
<div>
@@ -130,7 +131,29 @@ export function ContentSettings() {
</Collapsible>
</div>
{/* TODO: Sensitive Content Section */}
{/* Sensitive Content Section */}
<div>
<Collapsible open={sensitiveOpen} onOpenChange={setSensitiveOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none border-b-[4px] border-primary"
>
<span className="text-base font-semibold">Sensitive Content</span>
{sensitiveOpen ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="pb-4">
<SensitiveContentSection />
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
);
}
@@ -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 (
<div>
{/* Intro */}
<div className="flex items-center gap-4 px-3 pt-3 pb-4">
<div className="w-40 shrink-0 flex items-center justify-center">
<ShieldAlert className="size-16 text-muted-foreground/40" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold">Content Warnings</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
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.
</p>
</div>
</div>
{/* Policy radio group */}
<div className="px-3 pb-2">
<RadioGroup
value={config.contentWarningPolicy}
onValueChange={handlePolicyChange}
className="gap-0"
>
{CW_POLICY_OPTIONS.map((option) => (
<label
key={option.value}
className="flex items-start gap-3 py-3.5 px-1 border-b border-border last:border-b-0 cursor-pointer hover:bg-muted/20 transition-colors rounded-sm"
>
<RadioGroupItem value={option.value} className="mt-0.5 shrink-0" />
<div className="min-w-0">
<span className="text-sm font-medium">{option.label}</span>
<p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">
{option.description}
</p>
</div>
</label>
))}
</RadioGroup>
</div>
</div>
);
}
// Mute settings internals (without the intro/image)
import { Trash2, Plus, UserX, Hash, MessageSquareOff } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
+114
View File
@@ -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 (
<div className={cn('relative mt-2', className)}>
{/* Grey blur filler — mimics a content area so the card doesn't look empty */}
<div className="rounded-xl bg-muted/40 overflow-hidden">
{/* Fake content lines */}
<div className="px-4 pt-4 pb-2 space-y-2.5">
<div className="h-3.5 w-full rounded bg-muted/60" />
<div className="h-3.5 w-4/5 rounded bg-muted/60" />
<div className="h-3.5 w-3/5 rounded bg-muted/60" />
</div>
{/* Fake image block */}
<div className="mx-4 mb-4 mt-1 h-32 rounded-lg bg-muted/60" />
{/* Centered overlay content */}
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2.5 px-4 text-center">
<div className="flex items-center justify-center size-10 rounded-full bg-background/80 shadow-sm backdrop-blur-sm">
<ShieldAlert className="size-5 text-muted-foreground" />
</div>
<div className="space-y-1 max-w-xs">
<p className="text-sm font-medium text-foreground">Content Warning</p>
{reason ? (
<p className="text-xs text-muted-foreground leading-relaxed">
&ldquo;{reason}&rdquo;
</p>
) : (
<p className="text-xs text-muted-foreground leading-relaxed">
The author flagged this post as sensitive.
</p>
)}
</div>
<Button
variant="outline"
size="sm"
className="gap-1.5 mt-0.5 bg-background/80 backdrop-blur-sm"
onClick={(e) => {
e.stopPropagation();
setRevealed(true);
}}
>
<Eye className="size-3.5" />
Show Content
</Button>
</div>
</div>
</div>
);
}
+21 -5
View File
@@ -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 (
<div
className={cn(
@@ -141,8 +153,8 @@ function EmbeddedNoteCard({
}
}}
>
{/* Optional image thumbnail */}
{firstImage && (
{/* Optional image thumbnail — skip when content-warning is blurred */}
{firstImage && !(hasCW && config.contentWarningPolicy === 'blur') && (
<div className="w-full overflow-hidden">
<img
src={firstImage}
@@ -201,10 +213,14 @@ function EmbeddedNoteCard({
</span>
</div>
{/* Text preview with inline mentions */}
{truncatedContent && (
{/* Content warning notice or text preview */}
{hasCW && config.contentWarningPolicy === 'blur' ? (
<p className="text-xs text-muted-foreground italic">
Content warning{cwTag?.[1] ? <>{' '}&ldquo;{cwTag[1]}&rdquo;</> : ''}
</p>
) : truncatedContent ? (
<EmbedContentPreview text={truncatedContent} />
)}
) : null}
</div>
</div>
);
+5
View File
@@ -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]);
+35 -24
View File
@@ -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 (
<article
className={cn(
@@ -277,30 +286,32 @@ export function NoteCard({ event, className, repostedBy, compact }: NoteCardProp
<ReplyContext pubkeys={replyToPubkeys} />
)}
{/* Content — kind-based dispatch */}
{isVine ? (
<>
{vineTitle && <p className="text-[15px] mt-2 leading-relaxed break-words overflow-hidden">{vineTitle}</p>}
<VineMedia imeta={imeta} hashtags={hashtags} />
</>
) : isPoll ? (
<PollContent event={event} />
) : isGeocache ? (
<GeocacheContent event={event} />
) : isFoundLog ? (
<FoundLogContent event={event} />
) : isColor ? (
<ColorMomentContent event={event} />
) : isFollowPack ? (
<FollowPackContent event={event} />
) : (
<>
<div className="mt-2 break-words overflow-hidden">
<NoteContent event={event} className="text-[15px] leading-relaxed" />
</div>
<NoteMedia images={images} videos={videos} imetaMap={imetaMap} />
</>
)}
{/* Content — kind-based dispatch, guarded by NIP-36 content-warning */}
<ContentWarningGuard event={event}>
{isVine ? (
<>
{vineTitle && <p className="text-[15px] mt-2 leading-relaxed break-words overflow-hidden">{vineTitle}</p>}
<VineMedia imeta={imeta} hashtags={hashtags} />
</>
) : isPoll ? (
<PollContent event={event} />
) : isGeocache ? (
<GeocacheContent event={event} />
) : isFoundLog ? (
<FoundLogContent event={event} />
) : isColor ? (
<ColorMomentContent event={event} />
) : isFollowPack ? (
<FollowPackContent event={event} />
) : (
<>
<div className="mt-2 break-words overflow-hidden">
<NoteContent event={event} className="text-[15px] leading-relaxed" />
</div>
<NoteMedia images={images} videos={videos} imetaMap={imetaMap} />
</>
)}
</ContentWarningGuard>
{/* Action buttons — hidden in compact/embed mode */}
{!compact && (
+13
View File
@@ -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 {
+3 -1
View File
@@ -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 */
+42 -37
View File
@@ -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 }) {
)}
</div>
{/* Post content — kind-based dispatch (same as NoteCard) */}
{isVine || isPoll || isGeocache || isFoundLog || isColor || isFollowPack ? (
<>
{isVine && <VineDetailContent event={event} />}
{isPoll && <PollContent event={event} />}
{isGeocache && <GeocacheContent event={event} />}
{isFoundLog && <FoundLogContent event={event} />}
{isColor && <ColorMomentContent event={event} />}
{isFollowPack && <FollowPackContent event={event} />}
</>
) : (
<>
<div className="mt-3">
<NoteContent event={event} className="text-[15px] leading-relaxed" />
</div>
{videos.map((url, i) => (
<VideoPlayer key={`v-${i}`} src={url} poster={imetaMap.get(url)?.thumbnail} />
))}
<ImageGallery images={images} maxGridHeight="500px" />
</>
)}
{/* Post content — kind-based dispatch, guarded by NIP-36 content-warning */}
<ContentWarningGuard event={event}>
{isVine || isPoll || isGeocache || isFoundLog || isColor || isFollowPack ? (
<>
{isVine && <VineDetailContent event={event} />}
{isPoll && <PollContent event={event} />}
{isGeocache && <GeocacheContent event={event} />}
{isFoundLog && <FoundLogContent event={event} />}
{isColor && <ColorMomentContent event={event} />}
{isFollowPack && <FollowPackContent event={event} />}
</>
) : (
<>
<div className="mt-3">
<NoteContent event={event} className="text-[15px] leading-relaxed" />
</div>
{videos.map((url, i) => (
<VideoPlayer key={`v-${i}`} src={url} poster={imetaMap.get(url)?.thumbnail} />
))}
<ImageGallery images={images} maxGridHeight="500px" />
</>
)}
</ContentWarningGuard>
{/* 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 }) {
)}
</div>
{/* Note text */}
<div className="mt-1">
<NoteContent event={event} className="text-[15px] leading-relaxed" />
</div>
{/* Videos */}
{videos.map((url, i) => (
<div key={`v-${i}`} className="mt-3">
<VideoPlayer src={url} poster={imetaMap.get(url)?.thumbnail} />
{/* Note text + media, guarded by NIP-36 content-warning */}
<ContentWarningGuard event={event}>
<div className="mt-1">
<NoteContent event={event} className="text-[15px] leading-relaxed" />
</div>
))}
{/* Images */}
{images.length > 0 && (
<div className="mt-3">
<ImageGallery images={images} maxGridHeight="400px" />
</div>
)}
{/* Videos */}
{videos.map((url, i) => (
<div key={`v-${i}`} className="mt-3">
<VideoPlayer src={url} poster={imetaMap.get(url)?.thumbnail} />
</div>
))}
{/* Images */}
{images.length > 0 && (
<div className="mt-3">
<ImageGallery images={images} maxGridHeight="400px" />
</div>
)}
</ContentWarningGuard>
</div>
</div>
</div>
+1
View File
@@ -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 (