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 */}
+
+
+ {/* 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 */}