740fc1c63c
Pulls in 387 commits from ditto/main while preserving Agora-specific features. Where the two codebases diverged on the same concept, kept the Agora side per project direction. Kept Agora-specific: - SparkWallet stack (over Ditto's nostr-derived Bitcoin wallet) - Communities (NIP-72 + chat + members), Messages, Organizers, Actions, Verified, Appearance settings - DMProviderWrapper, country/organizer moderation in NoteMoreMenu - 'Agora' branding, pub.agora.app bundle ID, version 2.8.0 - Built-in theme system (src/themes.ts) only Rejected from Ditto: - All Blobbi virtual pet code (80+ files, route, provider, sidebar, kind labels, feed setting, NIP.md entries, CSS animations) - Custom theme events (kinds 36767/16767) — ThemesPage, ThemeContent, active profile themes, theme snapshot recovery - On-chain zaps (kind 8333) and the entire Bitcoin wallet implementation (useBitcoinWallet, bitcoin-signers, BitcoinContentHeader, bitcoinjs-lib / @bitcoinerlab/secp256k1 / ecpair / tiny-secp256k1) - ZapSuccessScreen (depended on dropped bitcoin lib) Pulled in from Ditto: - .agents/skills/* (12 new specialized skills, slim AGENTS.md) - @nostrify bumps to 0.52 / 0.6 / 0.37 - New routes/pages: Music, Podcasts, Videos, Vines, Wikipedia, Books, Bluesky, Archive, AIChat, Trends, Webxdc, Highlights, Decks, Emojis, Development, Treasures, Colors, Packs - Birdstar feed integration (kinds 2473, 12473, 30621) - Wikipedia/Wikidata/Scryfall lookup in ExternalContentPage - release-notes CI job + extract-release-notes.mjs script - nsite:// URI handling in feed/sidebar - iOS fastlane setup - src/lib/avatarShape.ts + Avatar shape prop (kept for new Music/People components that depend on it) Preserved Agora's ABSOLUTE 'NEVER COMMIT' rule at the top of AGENTS.md and dropped Ditto's contradicting 'Commit at the end of every task' section. Validation: npm run test passes (tsc, eslint, 40/40 vitest, vite build).
363 lines
14 KiB
TypeScript
363 lines
14 KiB
TypeScript
/**
|
|
* Rich detail view for podcast episodes (kind 30054) and trailers (kind 30055).
|
|
* Modeled after MusicDetailContent with play/pause hero, comments, and interactions.
|
|
*/
|
|
|
|
import { useMemo, useState } from 'react';
|
|
import { Link, useNavigate } from 'react-router-dom';
|
|
import { ArrowLeft, Play, Pause, Podcast, Zap, Clock } from 'lucide-react';
|
|
import { RepostIcon } from '@/components/icons/RepostIcon';
|
|
import type { NostrEvent } from '@nostrify/nostrify';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useEventStats } from '@/hooks/useTrending';
|
|
|
|
import { useComments } from '@/hooks/useComments';
|
|
import { useMuteList } from '@/hooks/useMuteList';
|
|
import { isEventMuted } from '@/lib/muteHelpers';
|
|
import { getDisplayName } from '@/lib/getDisplayName';
|
|
import { formatTime } from '@/lib/formatTime';
|
|
import { formatNumber } from '@/lib/formatNumber';
|
|
import { cn } from '@/lib/utils';
|
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
|
import { ReactionButton } from '@/components/ReactionButton';
|
|
import { RepostMenu } from '@/components/RepostMenu';
|
|
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
|
|
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
|
import { ZapDialog } from '@/components/ZapDialog';
|
|
import { InteractionsModal, type InteractionTab } from '@/components/InteractionsModal';
|
|
import { NoteCard } from '@/components/NoteCard';
|
|
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
|
|
import { parsePodcastEpisode, parsePodcastTrailer, episodeToAudioTrack, trailerToAudioTrack } from '@/lib/podcastHelpers';
|
|
|
|
/** Format a full date. */
|
|
function formatFullDate(ts: number): string {
|
|
return new Date(ts * 1000).toLocaleDateString(undefined, {
|
|
year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
export function PodcastDetailContent({ event }: { event: NostrEvent }) {
|
|
const isEpisode = event.kind === 30054;
|
|
return isEpisode ? <EpisodeDetail event={event} /> : <TrailerDetail event={event} />;
|
|
}
|
|
|
|
// ── Episode detail ────────────────────────────────────────────────────────────
|
|
|
|
function EpisodeDetail({ event }: { event: NostrEvent }) {
|
|
const navigate = useNavigate();
|
|
const player = useAudioPlayer();
|
|
const parsed = useMemo(() => parsePodcastEpisode(event), [event]);
|
|
|
|
const author = useAuthor(event.pubkey);
|
|
const metadata = author.data?.metadata;
|
|
const displayName = getDisplayName(metadata, event.pubkey);
|
|
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
|
const { user } = useCurrentUser();
|
|
|
|
const stats = useEventStats(event.id, event);
|
|
const { muteItems } = useMuteList();
|
|
|
|
const [replyOpen, setReplyOpen] = useState(false);
|
|
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
|
const [interactionsTab, setInteractionsTab] = useState<InteractionTab | null>(null);
|
|
|
|
// Comments (NIP-22)
|
|
const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500);
|
|
const comments = useMemo(() => {
|
|
const top = commentsData?.topLevelComments ?? [];
|
|
if (muteItems.length === 0) return top;
|
|
return top.filter((r) => !isEventMuted(r, muteItems));
|
|
}, [commentsData?.topLevelComments, muteItems]);
|
|
|
|
const hashtags = event.tags.filter(([n]) => n === 't').map(([, v]) => v);
|
|
|
|
const isNowPlaying = player.currentTrack?.id === event.id;
|
|
const dur = parsed?.duration ? formatTime(parsed.duration) : undefined;
|
|
|
|
const handlePlay = () => {
|
|
if (!parsed) return;
|
|
if (isNowPlaying && player.isPlaying) {
|
|
player.pause();
|
|
} else if (isNowPlaying) {
|
|
player.resume();
|
|
} else {
|
|
const track = episodeToAudioTrack(event, parsed);
|
|
track.artist = displayName;
|
|
track.artwork ??= metadata?.picture;
|
|
player.playTrack(track);
|
|
}
|
|
};
|
|
|
|
const zapAmount = stats.data?.zapAmount ?? 0;
|
|
|
|
return (
|
|
<main className="">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-4 px-4 mt-4 mb-4">
|
|
<button onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')} className="p-1.5 -ml-1.5 rounded-full hover:bg-secondary/60 transition-colors">
|
|
<ArrowLeft className="size-5" />
|
|
</button>
|
|
<h1 className="text-xl font-bold truncate">Episode Details</h1>
|
|
</div>
|
|
|
|
{/* Hero: artwork + info side by side */}
|
|
<div className="px-4 flex gap-5 items-start">
|
|
{/* Artwork */}
|
|
<div className="shrink-0 w-32 sm:w-40 aspect-square rounded-2xl overflow-hidden bg-muted shadow-lg">
|
|
{parsed?.artwork ? (
|
|
<img src={parsed.artwork} alt={parsed.title} className="w-full h-full object-cover" />
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center bg-primary/10">
|
|
<Podcast className="size-12 text-primary/30" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="flex-1 min-w-0 space-y-2 pt-1">
|
|
<h2 className="text-xl sm:text-2xl font-bold leading-tight">{parsed?.title ?? 'Untitled'}</h2>
|
|
|
|
<Link to={profileUrl} className="flex items-center gap-2 group" onClick={(e) => e.stopPropagation()}>
|
|
<Avatar className="size-6">
|
|
<AvatarImage src={metadata?.picture} alt={displayName} />
|
|
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">{displayName[0]?.toUpperCase()}</AvatarFallback>
|
|
</Avatar>
|
|
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">{displayName}</span>
|
|
</Link>
|
|
|
|
{/* Hashtags */}
|
|
{hashtags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
|
{hashtags.map((tag) => (
|
|
<Link
|
|
key={tag}
|
|
to={`/t/${encodeURIComponent(tag)}`}
|
|
className="text-xs px-2.5 py-1 rounded-full border border-border hover:border-primary/40 hover:bg-primary/5 transition-colors"
|
|
>
|
|
{tag}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Action row */}
|
|
<div className="flex items-center gap-2 pt-2">
|
|
<button
|
|
onClick={handlePlay}
|
|
className={cn(
|
|
'size-11 rounded-full flex items-center justify-center transition-colors',
|
|
isNowPlaying && player.isPlaying
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'bg-primary/15 text-primary hover:bg-primary/25',
|
|
)}
|
|
aria-label={isNowPlaying && player.isPlaying ? 'Pause' : 'Play'}
|
|
>
|
|
{isNowPlaying && player.isPlaying
|
|
? <Pause className="size-5" fill="currentColor" />
|
|
: <Play className="size-5 ml-0.5" fill="currentColor" />}
|
|
</button>
|
|
|
|
<ReactionButton
|
|
eventId={event.id}
|
|
eventPubkey={event.pubkey}
|
|
eventKind={event.kind}
|
|
reactionCount={stats.data?.reactions}
|
|
/>
|
|
|
|
<RepostMenu event={event}>
|
|
{(isReposted: boolean) => (
|
|
<button
|
|
className={cn(
|
|
'size-11 rounded-full flex items-center justify-center transition-colors',
|
|
isReposted ? 'text-accent hover:bg-accent/10' : 'bg-secondary/50 text-muted-foreground hover:bg-secondary',
|
|
)}
|
|
title="Repost"
|
|
>
|
|
<RepostIcon className="size-5" />
|
|
</button>
|
|
)}
|
|
</RepostMenu>
|
|
|
|
{user && user.pubkey !== event.pubkey && (
|
|
<ZapDialog target={event}>
|
|
<button
|
|
className="size-11 rounded-full bg-secondary/50 text-muted-foreground hover:bg-secondary flex items-center justify-center transition-colors"
|
|
title="Zap"
|
|
>
|
|
<Zap className="size-5" />
|
|
</button>
|
|
</ZapDialog>
|
|
)}
|
|
|
|
{/* Zap stats */}
|
|
{zapAmount > 0 && (
|
|
<button onClick={() => setInteractionsTab('zaps')} className="ml-1 text-right hover:opacity-80">
|
|
<p className="text-lg font-bold leading-tight">{formatNumber(zapAmount)} sats</p>
|
|
<p className="text-xs text-muted-foreground">{formatNumber(stats.data?.zapCount ?? 0)} zap{(stats.data?.zapCount ?? 0) !== 1 ? 's' : ''}</p>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{dur && (
|
|
<p className="text-xs text-muted-foreground flex items-center gap-1 pt-1">
|
|
<Clock className="size-3" />{dur}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
{parsed?.description && (
|
|
<div className="px-4 mt-4">
|
|
<p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">{parsed.description}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Date */}
|
|
<div className="px-4 mt-3 text-xs text-muted-foreground">
|
|
{formatFullDate(parsed?.pubdate ?? event.created_at)}
|
|
</div>
|
|
|
|
{/* Interactions tabs */}
|
|
<div className="flex border-b border-border mt-4">
|
|
<button
|
|
onClick={() => setInteractionsTab('zaps')}
|
|
className="flex-1 py-3 text-center text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-secondary/40 transition-colors"
|
|
>
|
|
Top Zappers
|
|
</button>
|
|
<button
|
|
onClick={() => setReplyOpen(true)}
|
|
className="flex-1 py-3 text-center text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-secondary/40 transition-colors"
|
|
>
|
|
Comments <span className="text-xs ml-1 opacity-70">{formatNumber(comments.length || 0)}</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setInteractionsTab('reactions')}
|
|
className="flex-1 py-3 text-center text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-secondary/40 transition-colors"
|
|
>
|
|
Reactions <span className="text-xs ml-1 opacity-70">{formatNumber(stats.data?.reactions || 0)}</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Comments list */}
|
|
{comments.length > 0 && (
|
|
<div>
|
|
{comments.map((comment) => (
|
|
<NoteCard key={comment.id} event={comment} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{comments.length === 0 && !commentsLoading && (
|
|
<div className="py-10 text-center text-sm text-muted-foreground">
|
|
No comments yet
|
|
</div>
|
|
)}
|
|
|
|
{/* Dialogs */}
|
|
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
|
|
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
|
|
{interactionsTab && (
|
|
<InteractionsModal
|
|
eventId={event.id}
|
|
initialTab={interactionsTab}
|
|
open={!!interactionsTab}
|
|
onOpenChange={(open) => { if (!open) setInteractionsTab(null); }}
|
|
/>
|
|
)}
|
|
</main>
|
|
);
|
|
}
|
|
|
|
// ── Trailer detail ────────────────────────────────────────────────────────────
|
|
|
|
function TrailerDetail({ event }: { event: NostrEvent }) {
|
|
const navigate = useNavigate();
|
|
const player = useAudioPlayer();
|
|
const parsed = useMemo(() => parsePodcastTrailer(event), [event]);
|
|
|
|
const author = useAuthor(event.pubkey);
|
|
const metadata = author.data?.metadata;
|
|
const displayName = getDisplayName(metadata, event.pubkey);
|
|
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
|
|
|
const isNowPlaying = player.currentTrack?.id === event.id;
|
|
|
|
const handlePlay = () => {
|
|
if (!parsed) return;
|
|
if (isNowPlaying && player.isPlaying) {
|
|
player.pause();
|
|
} else if (isNowPlaying) {
|
|
player.resume();
|
|
} else {
|
|
const track = trailerToAudioTrack(event, parsed);
|
|
track.artist = displayName;
|
|
track.artwork ??= metadata?.picture;
|
|
player.playTrack(track);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<main className="">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-4 px-4 mt-4 mb-4">
|
|
<button onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')} className="p-1.5 -ml-1.5 rounded-full hover:bg-secondary/60 transition-colors">
|
|
<ArrowLeft className="size-5" />
|
|
</button>
|
|
<h1 className="text-xl font-bold truncate">Trailer Details</h1>
|
|
</div>
|
|
|
|
{/* Hero */}
|
|
<div className="px-4 flex gap-5 items-start">
|
|
<div className="shrink-0 w-32 sm:w-40 aspect-square rounded-2xl overflow-hidden bg-muted shadow-lg">
|
|
<div className="w-full h-full flex items-center justify-center bg-primary/10">
|
|
<Podcast className="size-12 text-primary/30" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0 space-y-2 pt-1">
|
|
<h2 className="text-xl sm:text-2xl font-bold leading-tight">{parsed?.title ?? 'Untitled'}</h2>
|
|
|
|
<Link to={profileUrl} className="flex items-center gap-2 group">
|
|
<Avatar className="size-6">
|
|
<AvatarImage src={metadata?.picture} alt={displayName} />
|
|
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">{displayName[0]?.toUpperCase()}</AvatarFallback>
|
|
</Avatar>
|
|
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">{displayName}</span>
|
|
</Link>
|
|
|
|
{/* Play button */}
|
|
<div className="pt-2">
|
|
<button
|
|
onClick={handlePlay}
|
|
className={cn(
|
|
'size-11 rounded-full flex items-center justify-center transition-colors',
|
|
isNowPlaying && player.isPlaying
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'bg-primary/15 text-primary hover:bg-primary/25',
|
|
)}
|
|
aria-label={isNowPlaying && player.isPlaying ? 'Pause' : 'Play'}
|
|
>
|
|
{isNowPlaying && player.isPlaying
|
|
? <Pause className="size-5" fill="currentColor" />
|
|
: <Play className="size-5 ml-0.5" fill="currentColor" />}
|
|
</button>
|
|
</div>
|
|
|
|
{parsed?.season && (
|
|
<p className="text-xs text-muted-foreground pt-1">Season {parsed.season}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Date */}
|
|
<div className="px-4 mt-3 text-xs text-muted-foreground">
|
|
{formatFullDate(event.created_at)}
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|