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:
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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">
|
||||
“{reason}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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] ? <>{' '}“{cwTag[1]}”</> : ''}
|
||||
</p>
|
||||
) : truncatedContent ? (
|
||||
<EmbedContentPreview text={truncatedContent} />
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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 && (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user