From d0ef3e25751dd4e32c1f6ea7d87fded15eb41f5b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 20 Mar 2026 23:17:25 -0500 Subject: [PATCH] Add dramatic NIP-62 Request to Vanish display (kind 62) Implement a visually striking display for kind 62 vanish events with animated caution stripes, glitch effects, and a grand exit aesthetic. Since the user has nuked their account, the npub is shown instead of profile info. Supports both compact feed cards and full detail view with comments and sharing. --- src/components/NoteCard.tsx | 36 +++++- src/components/VanishEventContent.tsx | 167 ++++++++++++++++++++++++++ src/index.css | 89 ++++++++++++++ src/pages/PostDetailPage.tsx | 91 +++++++++++++- 4 files changed, 380 insertions(+), 3 deletions(-) create mode 100644 src/components/VanishEventContent.tsx 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 */}