Files
eranos/src/components/InteractionsModal.tsx
T
shakespeare.diy 3ce7cff7ab Remove border below post interactions header
- Remove Separator component between header and tabs
- Remove unused Separator import
- Cleaner visual separation in modal

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-02-18 20:45:08 -06:00

346 lines
12 KiB
TypeScript

import { useState, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Repeat2, Quote, Heart, Zap, X } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton';
import { useEventInteractions, type RepostEntry, type QuoteEntry, type ReactionEntry, type ZapEntry } from '@/hooks/useEventInteractions';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { timeAgo } from '@/lib/timeAgo';
import { cn } from '@/lib/utils';
export type InteractionTab = 'reposts' | 'quotes' | 'reactions' | 'zaps';
interface InteractionsModalProps {
eventId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
/** Which tab to show initially. */
initialTab?: InteractionTab;
}
/** Formats a sats amount into a compact human-readable string. */
function formatSats(sats: number): string {
if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
if (sats >= 1_000) return `${(sats / 1_000).toFixed(1).replace(/\.0$/, '')}K`;
return sats.toString();
}
export function InteractionsModal({ eventId, open, onOpenChange, initialTab = 'reposts' }: InteractionsModalProps) {
const [activeTab, setActiveTab] = useState<InteractionTab>(initialTab);
const { data, isLoading } = useEventInteractions(open ? eventId : undefined);
// Reset tab to initialTab when modal opens with a different tab
const handleOpenChange = (value: boolean) => {
if (value) setActiveTab(initialTab);
onOpenChange(value);
};
const repostCount = data?.reposts.length ?? 0;
const quoteCount = data?.quotes.length ?? 0;
const reactionCount = data?.reactions.length ?? 0;
const zapCount = data?.zaps.length ?? 0;
const tabConfig: { key: InteractionTab; label: string; count: number; icon: React.ReactNode }[] = [
{ key: 'reposts', label: 'Reposts', count: repostCount, icon: <Repeat2 className="size-4" /> },
{ key: 'quotes', label: 'Quotes', count: quoteCount, icon: <Quote className="size-4" /> },
{ key: 'reactions', label: 'Reactions', count: reactionCount, icon: <Heart className="size-4" /> },
{ key: 'zaps', label: 'Zaps', count: zapCount, icon: <Zap className="size-4" /> },
];
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-[460px] rounded-2xl p-0 gap-0 border-border overflow-hidden [&>button]:hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 h-12">
<DialogTitle className="text-base font-semibold">Post interactions</DialogTitle>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
>
<X className="size-5" />
</button>
</div>
{/* Tab bar */}
<div className="flex border-b border-border">
{tabConfig.map(({ key, label, count, icon }) => (
<button
key={key}
onClick={() => setActiveTab(key)}
className={cn(
'flex-1 flex items-center justify-center gap-1 py-3 text-[13px] font-medium transition-colors relative hover:bg-secondary/40 px-1',
activeTab === key ? 'text-foreground' : 'text-muted-foreground',
)}
>
{icon}
<span className="hidden min-[400px]:inline">{label}</span>
{count > 0 && (
<span className={cn(
'text-xs tabular-nums',
activeTab === key ? 'text-foreground' : 'text-muted-foreground',
)}>
{count}
</span>
)}
{activeTab === key && (
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-10 h-0.5 bg-primary rounded-full" />
)}
</button>
))}
</div>
{/* Content */}
<ScrollArea className="max-h-[60vh]">
{isLoading ? (
<div className="divide-y divide-border">
{Array.from({ length: 4 }).map((_, i) => (
<InteractionRowSkeleton key={i} />
))}
</div>
) : activeTab === 'reposts' ? (
<RepostsTab reposts={data?.reposts ?? []} />
) : activeTab === 'quotes' ? (
<QuotesTab quotes={data?.quotes ?? []} />
) : activeTab === 'reactions' ? (
<ReactionsTab reactions={data?.reactions ?? []} />
) : (
<ZapsTab zaps={data?.zaps ?? []} />
)}
</ScrollArea>
</DialogContent>
</Dialog>
);
}
/* ──── Reposts Tab ──── */
function RepostsTab({ reposts }: { reposts: RepostEntry[] }) {
if (reposts.length === 0) {
return <EmptyState message="No reposts yet" />;
}
return (
<div className="divide-y divide-border">
{reposts.map((repost, i) => (
<UserRow key={`${repost.pubkey}-${i}`} pubkey={repost.pubkey} subtitle={timeAgo(repost.createdAt)} />
))}
</div>
);
}
/* ──── Quotes Tab ──── */
function QuotesTab({ quotes }: { quotes: QuoteEntry[] }) {
if (quotes.length === 0) {
return <EmptyState message="No quotes yet" />;
}
return (
<div className="divide-y divide-border">
{quotes.map((quote, i) => (
<QuoteRow key={`${quote.pubkey}-${i}`} quote={quote} />
))}
</div>
);
}
/* ──── Reactions Tab ──── */
function ReactionsTab({ reactions }: { reactions: ReactionEntry[] }) {
// Group reactions by emoji
const grouped = useMemo(() => {
const groups = new Map<string, ReactionEntry[]>();
for (const r of reactions) {
const key = r.emoji;
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(r);
}
// Sort groups by count (most popular first)
return Array.from(groups.entries()).sort((a, b) => b[1].length - a[1].length);
}, [reactions]);
if (reactions.length === 0) {
return <EmptyState message="No reactions yet" />;
}
return (
<div>
{grouped.map(([emoji, entries]) => (
<div key={emoji}>
{/* Emoji group header */}
<div className="flex items-center gap-2 px-4 py-2 bg-secondary/30 sticky top-0 z-[1]">
<span className="text-lg">{emoji}</span>
<span className="text-xs text-muted-foreground font-medium">{entries.length}</span>
</div>
{/* Users who reacted with this emoji */}
<div className="divide-y divide-border">
{entries.map((entry, i) => (
<UserRow key={`${entry.pubkey}-${i}`} pubkey={entry.pubkey} subtitle={timeAgo(entry.createdAt)} />
))}
</div>
</div>
))}
</div>
);
}
/* ──── Zaps Tab ──── */
function ZapsTab({ zaps }: { zaps: ZapEntry[] }) {
if (zaps.length === 0) {
return <EmptyState message="No zaps yet" />;
}
const totalSats = zaps.reduce((sum, z) => sum + z.amountSats, 0);
return (
<div>
{/* Total */}
<div className="flex items-center justify-center gap-2 px-4 py-3 bg-secondary/30 border-b border-border">
<Zap className="size-4 text-amber-500 fill-amber-500" />
<span className="text-sm font-bold text-amber-500">{formatSats(totalSats)} sats</span>
<span className="text-xs text-muted-foreground">from {zaps.length} zap{zaps.length !== 1 ? 's' : ''}</span>
</div>
<div className="divide-y divide-border">
{zaps.map((zap, i) => (
<ZapRow key={`${zap.senderPubkey}-${i}`} zap={zap} />
))}
</div>
</div>
);
}
/* ──── Shared Row Components ──── */
function UserRow({ pubkey, subtitle }: { pubkey: string; subtitle?: string }) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name || genUserName(pubkey);
const npub = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
return (
<Link
to={`/${npub}`}
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors"
>
<Avatar className="size-10 shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-bold text-sm truncate">{displayName}</span>
{metadata?.nip05 && (
<span className="text-xs text-muted-foreground truncate">@{metadata.nip05}</span>
)}
</div>
{subtitle && (
<span className="text-xs text-muted-foreground">{subtitle}</span>
)}
</div>
</Link>
);
}
function ZapRow({ zap }: { zap: ZapEntry }) {
const author = useAuthor(zap.senderPubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name || genUserName(zap.senderPubkey);
const npub = useMemo(() => nip19.npubEncode(zap.senderPubkey), [zap.senderPubkey]);
return (
<Link
to={`/${npub}`}
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors"
>
<Avatar className="size-10 shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-bold text-sm truncate">{displayName}</span>
{metadata?.nip05 && (
<span className="text-xs text-muted-foreground truncate">@{metadata.nip05}</span>
)}
</div>
{zap.message && (
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">{zap.message}</p>
)}
</div>
{/* Zap amount badge */}
<div className="flex items-center gap-1 shrink-0 bg-amber-500/10 text-amber-500 rounded-full px-2.5 py-1">
<Zap className="size-3.5 fill-amber-500" />
<span className="text-xs font-bold tabular-nums">{formatSats(zap.amountSats)}</span>
</div>
</Link>
);
}
function QuoteRow({ quote }: { quote: QuoteEntry }) {
const author = useAuthor(quote.pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name || genUserName(quote.pubkey);
const nevent = useMemo(() => nip19.neventEncode({ id: quote.eventId, author: quote.pubkey }), [quote.eventId, quote.pubkey]);
return (
<Link
to={`/${nevent}`}
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors"
>
<Avatar className="size-10 shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-bold text-sm truncate">{displayName}</span>
{metadata?.nip05 && (
<span className="text-xs text-muted-foreground truncate">@{metadata.nip05}</span>
)}
</div>
{quote.content && (
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">{quote.content}</p>
)}
<span className="text-xs text-muted-foreground">{timeAgo(quote.createdAt)}</span>
</div>
</Link>
);
}
function EmptyState({ message }: { message: string }) {
return (
<div className="py-12 text-center text-muted-foreground text-sm">
{message}
</div>
);
}
function InteractionRowSkeleton() {
return (
<div className="flex items-center gap-3 px-4 py-3">
<Skeleton className="size-10 rounded-full shrink-0" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-16" />
</div>
</div>
);
}