diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index cba4888e..9a2eb81f 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -62,6 +62,7 @@ import { ReplyComposeModal } from "@/components/ReplyComposeModal"; import { ReplyContext } from "@/components/ReplyContext"; import { RepostMenu } from "@/components/RepostMenu"; import { ThemeContent } from "@/components/ThemeContent"; +import { VanishEventContent } from "@/components/VanishEventContent"; import { ZapstoreAppContent } from "@/components/ZapstoreAppContent"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { getAvatarShape } from "@/lib/avatarShape"; @@ -257,6 +258,7 @@ export function NoteCard({ const isPullRequest = event.kind === 1618; const isCustomNip = event.kind === 30817; const isZapstoreApp = event.kind === 32267; + const isVanish = event.kind === 62; const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip; const isTextNote = !isVine && @@ -279,7 +281,8 @@ export function NoteCard({ !isVideo && !isAudioKind && !isDevKind && - !isZapstoreApp; + !isZapstoreApp && + !isVanish; // Kind 1 specific — images now render inline in NoteContent, only videos go to NoteMedia const videos = useMemo( @@ -627,6 +630,37 @@ export function NoteCard({ ); + // ── Vanish layout (kind 62) — dramatic card, no author row ── + if (isVanish) { + return ( +
+ + {!compact && ( + <> + {actionButtons} + + + + )} +
+ ); + } + // ── Reaction layout (kind 7) — compact activity-style card ── if (isReaction) { // Threaded reaction (used in AncestorThread with connector line) diff --git a/src/components/VanishEventContent.tsx b/src/components/VanishEventContent.tsx new file mode 100644 index 00000000..46b49d9b --- /dev/null +++ b/src/components/VanishEventContent.tsx @@ -0,0 +1,167 @@ +import type { NostrEvent } from '@nostrify/nostrify'; +import { nip19 } from 'nostr-tools'; +import { useMemo } from 'react'; + +interface VanishEventContentProps { + event: NostrEvent; + /** Compact mode for feed cards — shorter layout */ + compact?: boolean; +} + +/** + * Dramatic display for NIP-62 Request to Vanish (kind 62) events. + * + * These events represent a user permanently erasing their entire identity + * from the Nostr network. The display is intentionally theatrical — red + * caution stripes, glitch effects, and a "grand exit" aesthetic. + */ +export function VanishEventContent({ event, compact }: VanishEventContentProps) { + const npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]); + const relayTags = event.tags.filter(([n]) => n === 'relay'); + const isGlobal = relayTags.some(([, v]) => v === 'ALL_RELAYS'); + const relayList = relayTags.map(([, v]) => v).filter((v) => v !== 'ALL_RELAYS'); + const reason = event.content || undefined; + + const formattedDate = new Date(event.created_at * 1000).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + + if (compact) { + return ( +
+ {/* Caution stripe header */} +
+ +
+ {/* Glitch icon */} +
+
+ /// +
+
+ ! +
+
+ +
+

+ {isGlobal ? 'Global Request to Vanish' : 'Request to Vanish'} +

+

+ {npub} +

+ {reason && ( +

+ “{reason}” +

+ )} +
+
+ +
+
+ ); + } + + return ( +
+ {/* Top caution stripe band */} +
+ + {/* Main card body */} +
+ {/* Subtle diagonal line pattern in background */} +
+ + {/* Glitch icon cluster */} +
+
+ {/* Outer ring pulse */} +
+
+ + {/* Main icon */} +
+ /// +
+ + {/* Alert badge */} +
+ ! +
+
+
+ + {/* Title */} +
+

+ {isGlobal ? 'Global Request to Vanish' : 'Request to Vanish'} +

+

+ {isGlobal + ? 'This identity has requested permanent erasure from all Nostr relays.' + : `This identity has requested permanent erasure from ${relayList.length} relay${relayList.length !== 1 ? 's' : ''}.`} +

+
+ + {/* Identity card */} +
+
+
+ Identity +
+

+ {npub} +

+
+ + {/* Reason quote (if provided) */} + {reason && ( +
+

+ Final words +

+
+ “{reason}” +
+
+ )} + + {/* Relay targets (if specific relays listed) */} + {!isGlobal && relayList.length > 0 && ( +
+

+ Target relays +

+
+ {relayList.map((relay) => ( +

+ {relay} +

+ ))} +
+
+ )} + + {/* Timestamp */} +

+ {formattedDate} +

+
+ + {/* Bottom caution stripe band */} +
+
+ ); +} diff --git a/src/index.css b/src/index.css index 51466472..595714e1 100644 --- a/src/index.css +++ b/src/index.css @@ -164,4 +164,93 @@ animation: landing-hero-fade-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards; } +/* ── NIP-62 Request to Vanish ───────────────────────────────────────────────── */ + +/* Animated caution stripes — red/dark diagonal bands that scroll */ +.vanish-stripes { + background: repeating-linear-gradient( + -45deg, + hsl(0 72% 51% / 0.7), + hsl(0 72% 51% / 0.7) 10px, + hsl(0 72% 51% / 0.25) 10px, + hsl(0 72% 51% / 0.25) 20px + ); + background-size: 28.28px 28.28px; + animation: vanish-stripe-scroll 1.5s linear infinite; +} + +:is(.dark) .vanish-stripes { + background: repeating-linear-gradient( + -45deg, + hsl(0 72% 51% / 0.6), + hsl(0 72% 51% / 0.6) 10px, + hsl(0 72% 51% / 0.15) 10px, + hsl(0 72% 51% / 0.15) 20px + ); + background-size: 28.28px 28.28px; + animation: vanish-stripe-scroll 1.5s linear infinite; +} + +@keyframes vanish-stripe-scroll { + from { background-position: 0 0; } + to { background-position: 28.28px 0; } +} + +/* Glitch text effect on the /// symbol */ +.vanish-glitch-text { + position: relative; + animation: vanish-glitch 3s ease-in-out infinite; +} + +@keyframes vanish-glitch { + 0%, 90%, 100% { opacity: 1; transform: translate(0); } + 92% { opacity: 0.8; transform: translate(-2px, 1px); } + 94% { opacity: 0.6; transform: translate(2px, -1px); } + 96% { opacity: 0.9; transform: translate(-1px, -1px); } + 98% { opacity: 1; transform: translate(1px, 0); } +} + +/* Ring pulse animation for the icon */ +.vanish-ring-pulse { + animation: vanish-ring 3s ease-out infinite; +} + +.vanish-ring-pulse-delayed { + animation: vanish-ring 3s ease-out 1.5s infinite; +} + +@keyframes vanish-ring { + 0% { transform: scale(1); opacity: 0.4; } + 50% { transform: scale(1.15); opacity: 0; } + 100% { transform: scale(1); opacity: 0; } +} + +/* Slow blink for the status dot */ +.vanish-blink { + animation: vanish-blink-kf 2s ease-in-out infinite; +} + +@keyframes vanish-blink-kf { + 0%, 40%, 100% { opacity: 1; } + 50%, 90% { opacity: 0.2; } +} + +/* Reduced motion: disable all vanish animations */ +@media (prefers-reduced-motion: reduce) { + .vanish-stripes { + animation: none; + } + .vanish-glitch-text { + animation: none; + } + .vanish-ring-pulse, + .vanish-ring-pulse-delayed { + animation: none; + opacity: 0; + } + .vanish-blink { + animation: none; + } +} + diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index 51c8b0fb..605ca0a5 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -69,6 +69,7 @@ import { } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; +import { VanishEventContent } from "@/components/VanishEventContent"; import { VideoPlayer } from "@/components/VideoPlayer"; import { VoiceMessagePlayer } from "@/components/VoiceMessagePlayer"; import { WebxdcEmbed } from "@/components/WebxdcEmbed"; @@ -99,6 +100,9 @@ const BADGE_DEFINITION_KIND = 30009; /** Kind 31985 = Bookstr book reviews. */ const BOOK_REVIEW_KIND = 31985; +/** NIP-62 Request to Vanish. */ +const VANISH_KIND = 62; + /** Map a kind number to a human-readable shell title for the loading state. */ function shellTitleForKind(kind?: number): string { if (!kind) return "Loading..."; @@ -114,6 +118,7 @@ function shellTitleForKind(kind?: number): string { if (kind === BADGE_DEFINITION_KIND) return "Badge Details"; if (kind === BOOK_REVIEW_KIND) return "Book Review"; if (kind === 32267) return "App Details"; + if (kind === VANISH_KIND) return "Request to Vanish"; return "Post Details"; } @@ -804,6 +809,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) { const isPullRequest = event.kind === 1618; const isCustomNip = event.kind === 30817; const isZapstoreApp = event.kind === 32267; + const isVanish = event.kind === VANISH_KIND; const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip; const isTextNote = !isVine && @@ -822,7 +828,8 @@ function PostDetailContent({ event }: { event: NostrEvent }) { !isVideo && !isCommunity && !isDevKind && - !isZapstoreApp; + !isZapstoreApp && + !isVanish; const videos = useMemo( () => (isTextNote ? extractVideoUrls(event.content) : []), @@ -1305,8 +1312,88 @@ function PostDetailContent({ event }: { event: NostrEvent }) { )} + {/* Kind 62 — Request to Vanish: dramatic full-width display, no author row */} + {isVanish && ( +
+ + + {/* Date row */} +
+ {formatFullDate(event.created_at)} +
+ + {/* Action buttons — same as standard post detail */} +
+ + + + {(isReposted: boolean) => ( + + )} + + + + + + + +
+ + + + +
+ )} + {/* Main post — expanded Ditto-style view */} - {!isReaction && ( + {!isReaction && !isVanish && (
{/* Author row */}