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.
This commit is contained in:
@@ -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({
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Vanish layout (kind 62) — dramatic card, no author row ──
|
||||
if (isVanish) {
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
onClick={handleCardClick}
|
||||
onAuxClick={handleAuxClick}
|
||||
>
|
||||
<VanishEventContent event={event} compact />
|
||||
{!compact && (
|
||||
<>
|
||||
{actionButtons}
|
||||
<NoteMoreMenu
|
||||
event={event}
|
||||
open={moreMenuOpen}
|
||||
onOpenChange={setMoreMenuOpen}
|
||||
/>
|
||||
<ReplyComposeModal
|
||||
event={event}
|
||||
open={replyOpen}
|
||||
onOpenChange={setReplyOpen}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Reaction layout (kind 7) — compact activity-style card ──
|
||||
if (isReaction) {
|
||||
// Threaded reaction (used in AncestorThread with connector line)
|
||||
|
||||
@@ -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 (
|
||||
<div className="mt-2 space-y-2">
|
||||
{/* Caution stripe header */}
|
||||
<div className="vanish-stripes h-2 rounded-full" />
|
||||
|
||||
<div className="flex items-start gap-3 rounded-xl border-2 border-red-500/30 bg-red-500/5 p-3">
|
||||
{/* Glitch icon */}
|
||||
<div className="relative shrink-0 mt-0.5">
|
||||
<div className="size-10 rounded-full bg-red-500/10 border-2 border-red-500/30 flex items-center justify-center">
|
||||
<span className="text-lg vanish-glitch-text" data-text="///">///</span>
|
||||
</div>
|
||||
<div className="absolute -bottom-0.5 -right-0.5 size-4 rounded-full bg-red-600 flex items-center justify-center">
|
||||
<span className="text-[8px] font-black text-white leading-none">!</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-bold text-red-500 dark:text-red-400">
|
||||
{isGlobal ? 'Global Request to Vanish' : 'Request to Vanish'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 font-mono truncate">
|
||||
{npub}
|
||||
</p>
|
||||
{reason && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2 italic">
|
||||
“{reason}”
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vanish-stripes h-2 rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-0">
|
||||
{/* Top caution stripe band */}
|
||||
<div className="vanish-stripes h-3 rounded-t-xl" />
|
||||
|
||||
{/* Main card body */}
|
||||
<div className="relative border-x-2 border-red-500/30 bg-gradient-to-b from-red-500/[0.06] to-red-500/[0.02] dark:from-red-500/[0.08] dark:to-red-500/[0.03] px-5 py-6 overflow-hidden isolate">
|
||||
{/* Subtle diagonal line pattern in background */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.03] dark:opacity-[0.05] -z-10 pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: 'repeating-linear-gradient(45deg, currentColor 0, currentColor 1px, transparent 0, transparent 50%)',
|
||||
backgroundSize: '12px 12px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Glitch icon cluster */}
|
||||
<div className="flex justify-center mb-5">
|
||||
<div className="relative">
|
||||
{/* Outer ring pulse */}
|
||||
<div className="absolute inset-0 rounded-full border-2 border-red-500/20 vanish-ring-pulse" />
|
||||
<div className="absolute -inset-2 rounded-full border border-red-500/10 vanish-ring-pulse-delayed" />
|
||||
|
||||
{/* Main icon */}
|
||||
<div className="relative size-16 rounded-full bg-red-500/10 border-2 border-red-500/40 flex items-center justify-center shadow-lg shadow-red-500/10">
|
||||
<span className="text-2xl font-black vanish-glitch-text text-red-500 dark:text-red-400" data-text="///">///</span>
|
||||
</div>
|
||||
|
||||
{/* Alert badge */}
|
||||
<div className="absolute -bottom-1 -right-1 size-6 rounded-full bg-red-600 border-2 border-background flex items-center justify-center shadow-md">
|
||||
<span className="text-xs font-black text-white leading-none">!</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-lg font-black tracking-tight text-red-500 dark:text-red-400 uppercase">
|
||||
{isGlobal ? 'Global Request to Vanish' : 'Request to Vanish'}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed max-w-sm mx-auto">
|
||||
{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' : ''}.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Identity card */}
|
||||
<div className="mt-5 rounded-lg border border-red-500/20 bg-red-500/[0.04] dark:bg-red-500/[0.06] p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground uppercase tracking-wider font-bold">
|
||||
<div className="size-2 rounded-full bg-red-500 vanish-blink" />
|
||||
<span>Identity</span>
|
||||
</div>
|
||||
<p className="font-mono text-xs text-foreground/80 break-all leading-relaxed select-all">
|
||||
{npub}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reason quote (if provided) */}
|
||||
{reason && (
|
||||
<div className="mt-4 rounded-lg border border-border/50 bg-background/50 p-4">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider font-bold mb-2">
|
||||
Final words
|
||||
</p>
|
||||
<blockquote className="text-sm text-foreground/80 italic leading-relaxed pl-3 border-l-2 border-red-500/40">
|
||||
“{reason}”
|
||||
</blockquote>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Relay targets (if specific relays listed) */}
|
||||
{!isGlobal && relayList.length > 0 && (
|
||||
<div className="mt-4 rounded-lg border border-border/50 bg-background/50 p-4">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider font-bold mb-2">
|
||||
Target relays
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{relayList.map((relay) => (
|
||||
<p key={relay} className="font-mono text-xs text-foreground/70">
|
||||
{relay}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamp */}
|
||||
<p className="text-center text-xs text-muted-foreground mt-5">
|
||||
{formattedDate}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bottom caution stripe band */}
|
||||
<div className="vanish-stripes h-3 rounded-b-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 }) {
|
||||
</article>
|
||||
)}
|
||||
|
||||
{/* Kind 62 — Request to Vanish: dramatic full-width display, no author row */}
|
||||
{isVanish && (
|
||||
<article ref={focusedPostRef} className="px-4 pt-3 pb-0">
|
||||
<VanishEventContent event={event} />
|
||||
|
||||
{/* Date row */}
|
||||
<div className="py-2 sidebar:py-2.5 mt-2 sidebar:mt-3 text-xs sidebar:text-sm text-muted-foreground flex items-center gap-1.5">
|
||||
<span>{formatFullDate(event.created_at)}</span>
|
||||
</div>
|
||||
|
||||
{/* Action buttons — same as standard post detail */}
|
||||
<div className="flex items-center justify-between py-1 border-t border-b border-border -mx-4 px-4">
|
||||
<button
|
||||
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
title="Reply"
|
||||
onClick={() => setReplyOpen(true)}
|
||||
>
|
||||
<MessageCircle className="size-5" />
|
||||
{stats?.replies ? (
|
||||
<span className="text-sm tabular-nums">{formatNumber(stats.replies)}</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
<RepostMenu event={event}>
|
||||
{(isReposted: boolean) => (
|
||||
<button
|
||||
className={`flex items-center gap-1.5 p-2 rounded-full transition-colors ${isReposted ? "text-accent hover:text-accent/80 hover:bg-accent/10" : "text-muted-foreground hover:text-accent hover:bg-accent/10"}`}
|
||||
title={isReposted ? "Undo repost" : "Reposts"}
|
||||
>
|
||||
<RepostIcon className="size-5" />
|
||||
{repostTotal ? (
|
||||
<span className="text-sm tabular-nums">{formatNumber(repostTotal)}</span>
|
||||
) : null}
|
||||
</button>
|
||||
)}
|
||||
</RepostMenu>
|
||||
|
||||
<ReactionButton
|
||||
eventId={event.id}
|
||||
eventPubkey={event.pubkey}
|
||||
eventKind={event.kind}
|
||||
reactionCount={stats?.reactions}
|
||||
/>
|
||||
|
||||
<button
|
||||
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors sidebar:hidden"
|
||||
title="Share"
|
||||
onClick={handleShare}
|
||||
>
|
||||
<Share2 className="size-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
title="More"
|
||||
onClick={() => setMoreMenuOpen(true)}
|
||||
>
|
||||
<MoreHorizontal className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<NoteMoreMenu
|
||||
event={event}
|
||||
open={moreMenuOpen}
|
||||
onOpenChange={setMoreMenuOpen}
|
||||
/>
|
||||
<ReplyComposeModal
|
||||
event={event}
|
||||
open={replyOpen}
|
||||
onOpenChange={setReplyOpen}
|
||||
/>
|
||||
<InteractionsModal
|
||||
eventId={event.id}
|
||||
open={interactionsOpen}
|
||||
onOpenChange={setInteractionsOpen}
|
||||
initialTab={interactionsTab}
|
||||
/>
|
||||
</article>
|
||||
)}
|
||||
|
||||
{/* Main post — expanded Ditto-style view */}
|
||||
{!isReaction && (
|
||||
{!isReaction && !isVanish && (
|
||||
<article ref={focusedPostRef} className="px-4 pt-3 pb-0">
|
||||
{/* Author row */}
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
Reference in New Issue
Block a user