Files
eranos/src/components/InteractionsModal.tsx
T
Alex Gleason 62c026a9a3 Make repost rows in InteractionsModal link to the repost event
Replace UserRow (profile link) with RepostRow (nevent link) in the
reposts tab so clicking a repost navigates to the repost event detail
view, matching the existing behavior for reactions.
2026-03-25 19:13:38 -05:00

413 lines
15 KiB
TypeScript

import { useState, useMemo, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Quote, Heart, Zap, X, ChevronRight } from 'lucide-react';
import { RepostIcon } from '@/components/icons/RepostIcon';
import { nip19 } from 'nostr-tools';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton';
import { CustomEmojiImg, EmojifiedText } from '@/components/CustomEmoji';
import { isCustomEmoji } from '@/lib/customEmoji';
import { useEventInteractions, type RepostEntry, type QuoteEntry, type ReactionEntry, type ZapEntry } from '@/hooks/useEventInteractions';
import { useAuthor } from '@/hooks/useAuthor';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
import { genUserName } from '@/lib/genUserName';
import { timeAgo } from '@/lib/timeAgo';
import { formatNumber } from '@/lib/formatNumber';
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;
}
export function InteractionsModal({ eventId, open, onOpenChange, initialTab = 'reposts' }: InteractionsModalProps) {
const [activeTab, setActiveTab] = useState<InteractionTab>(initialTab);
const { data, isLoading } = useEventInteractions(open ? eventId : undefined);
// Sync active tab whenever initialTab changes (e.g. clicking a different stat while modal is already open)
useEffect(() => {
setActiveTab(initialTab);
}, [initialTab]);
const handleOpenChange = (value: boolean) => {
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: <RepostIcon 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',
)}>
{formatNumber(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) => (
<RepostRow key={`${repost.pubkey}-${i}`} entry={repost} />
))}
</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]) => {
// Check if this is a custom emoji — use the URL from the first entry
const firstEntry = entries[0];
const customUrl = firstEntry?.emojiUrl;
const customName = isCustomEmoji(emoji) ? emoji.slice(1, -1) : undefined;
return (
<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]">
{customUrl && customName ? (
<CustomEmojiImg name={customName} url={customUrl} className="inline-block h-6 w-6" />
) : (
<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 — each row links to the reaction event */}
<div className="divide-y divide-border">
{entries.map((entry, i) => (
<ReactionRow key={`${entry.pubkey}-${i}`} entry={entry} />
))}
</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">{formatNumber(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 RepostRow({ entry }: { entry: RepostEntry }) {
const author = useAuthor(entry.pubkey);
const metadata = author.data?.metadata;
const avatarShape = getAvatarShape(metadata);
const displayName = metadata?.name || genUserName(entry.pubkey);
const nevent = useMemo(() => nip19.neventEncode({ id: entry.eventId, author: entry.pubkey }), [entry.eventId, entry.pubkey]);
return (
<Link
to={`/${nevent}`}
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors"
>
<Avatar shape={avatarShape} 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">
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
) : displayName}
</span>
{metadata?.nip05 && (
<VerifiedNip05Text nip05={metadata.nip05} pubkey={entry.pubkey} className="text-xs text-muted-foreground truncate" />
)}
</div>
<span className="text-xs text-muted-foreground">{timeAgo(entry.createdAt)}</span>
</div>
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
</Link>
);
}
function ReactionRow({ entry }: { entry: ReactionEntry }) {
const author = useAuthor(entry.pubkey);
const metadata = author.data?.metadata;
const avatarShape = getAvatarShape(metadata);
const displayName = metadata?.name || genUserName(entry.pubkey);
const nevent = useMemo(() => nip19.neventEncode({ id: entry.eventId, author: entry.pubkey }), [entry.eventId, entry.pubkey]);
return (
<Link
to={`/${nevent}`}
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors"
>
<Avatar shape={avatarShape} 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">
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
) : displayName}
</span>
{metadata?.nip05 && (
<VerifiedNip05Text nip05={metadata.nip05} pubkey={entry.pubkey} className="text-xs text-muted-foreground truncate" />
)}
</div>
<span className="text-xs text-muted-foreground">{timeAgo(entry.createdAt)}</span>
</div>
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
</Link>
);
}
function ZapRow({ zap }: { zap: ZapEntry }) {
const author = useAuthor(zap.senderPubkey);
const metadata = author.data?.metadata;
const avatarShape = getAvatarShape(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 shape={avatarShape} 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">
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
) : displayName}
</span>
{metadata?.nip05 && (
<VerifiedNip05Text nip05={metadata.nip05} pubkey={zap.senderPubkey} className="text-xs text-muted-foreground truncate" />
)}
</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">{formatNumber(zap.amountSats)}</span>
</div>
</Link>
);
}
function QuoteRow({ quote }: { quote: QuoteEntry }) {
const author = useAuthor(quote.pubkey);
const metadata = author.data?.metadata;
const avatarShape = getAvatarShape(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 shape={avatarShape} 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">
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
) : displayName}
</span>
{metadata?.nip05 && (
<VerifiedNip05Text nip05={metadata.nip05} pubkey={quote.pubkey} className="text-xs text-muted-foreground truncate" />
)}
</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>
);
}