391 lines
14 KiB
TypeScript
391 lines
14 KiB
TypeScript
import { useMemo, useState, useEffect } from 'react';
|
|
import { Link, useNavigate } from 'react-router-dom';
|
|
import { ArrowLeft, Users, Radio, Zap, Clock, ChevronDown, ChevronUp } from 'lucide-react';
|
|
import type { NostrEvent } from '@nostrify/nostrify';
|
|
import { useSeoMeta } from '@unhead/react';
|
|
|
|
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
|
import { LiveStreamPlayer } from '@/components/LiveStreamPlayer';
|
|
import { LiveStreamChat } from '@/components/LiveStreamChat';
|
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { ZapDialog } from '@/components/ZapDialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
|
import { EmojifiedText } from '@/components/CustomEmoji';
|
|
import { Nip05Badge } from '@/components/Nip05Badge';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { getDisplayName } from '@/lib/getDisplayName';
|
|
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
|
import { canZap } from '@/lib/canZap';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
/** Extract the first value of a tag by name. */
|
|
function getTag(tags: string[][], name: string): string | undefined {
|
|
return tags.find(([n]) => n === name)?.[1];
|
|
}
|
|
|
|
/** Parse participant entries from p tags. */
|
|
interface Participant {
|
|
pubkey: string;
|
|
relay?: string;
|
|
role?: string;
|
|
}
|
|
|
|
function parseParticipants(tags: string[][]): Participant[] {
|
|
return tags
|
|
.filter(([name]) => name === 'p')
|
|
.map(([, pubkey, relay, role]) => ({ pubkey, relay, role }));
|
|
}
|
|
|
|
/** Status badge colors and labels. */
|
|
function getStatusConfig(status: string | undefined) {
|
|
switch (status) {
|
|
case 'live':
|
|
return { label: 'LIVE', className: 'bg-red-600 hover:bg-red-600 text-white border-red-600 animate-pulse' };
|
|
case 'ended':
|
|
return { label: 'ENDED', className: 'bg-muted text-muted-foreground border-border' };
|
|
case 'planned':
|
|
return { label: 'PLANNED', className: 'bg-blue-600/90 hover:bg-blue-600/90 text-white border-blue-600' };
|
|
default:
|
|
return { label: status?.toUpperCase() || 'UNKNOWN', className: 'bg-muted text-muted-foreground border-border' };
|
|
}
|
|
}
|
|
|
|
/** Format a unix timestamp to a readable date/time. */
|
|
function formatDateTime(timestamp: number): string {
|
|
const date = new Date(timestamp * 1000);
|
|
return date.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
hour12: true,
|
|
});
|
|
}
|
|
|
|
interface LiveStreamPageProps {
|
|
event: NostrEvent;
|
|
}
|
|
|
|
export function LiveStreamPage({ event }: LiveStreamPageProps) {
|
|
const navigate = useNavigate();
|
|
const { user } = useCurrentUser();
|
|
const [descExpanded, setDescExpanded] = useState(false);
|
|
|
|
const title = getTag(event.tags, 'title') || 'Untitled Stream';
|
|
const summary = getTag(event.tags, 'summary');
|
|
const streamUrl = getTag(event.tags, 'streaming');
|
|
const recordingUrl = getTag(event.tags, 'recording');
|
|
const imageUrl = getTag(event.tags, 'image');
|
|
const status = getTag(event.tags, 'status');
|
|
const currentParticipants = getTag(event.tags, 'current_participants');
|
|
const starts = getTag(event.tags, 'starts');
|
|
const hashtags = event.tags.filter(([n]) => n === 't').map(([, v]) => v);
|
|
const participants = useMemo(() => parseParticipants(event.tags), [event.tags]);
|
|
|
|
const statusConfig = getStatusConfig(status);
|
|
const hasDescription = !!(summary || hashtags.length > 0 || participants.length > 0);
|
|
|
|
// Build the a-tag for live chat: 30311:<pubkey>:<d-tag>
|
|
const dTag = getTag(event.tags, 'd') || '';
|
|
const aTag = `30311:${event.pubkey}:${dTag}`;
|
|
|
|
// The URL to play: prefer streaming for live, recording for ended
|
|
const playUrl = status === 'ended' ? (recordingUrl || streamUrl) : streamUrl;
|
|
|
|
useSeoMeta({ title: `${title} - Ditto` });
|
|
|
|
// Lock body scroll on mobile to prevent page scrolling past the viewport-locked layout
|
|
useEffect(() => {
|
|
const mq = window.matchMedia('(max-width: 899px)');
|
|
const apply = (matches: boolean) => {
|
|
document.body.style.overflow = matches ? 'hidden' : '';
|
|
};
|
|
apply(mq.matches);
|
|
const handler = (e: MediaQueryListEvent) => apply(e.matches);
|
|
mq.addEventListener('change', handler);
|
|
return () => {
|
|
mq.removeEventListener('change', handler);
|
|
document.body.style.overflow = '';
|
|
};
|
|
}, []);
|
|
|
|
const chatSidebar = (
|
|
<aside className="hidden xl:flex xl:flex-col xl:w-[340px] xl:shrink-0 h-screen sticky top-0">
|
|
<LiveStreamChat aTag={aTag} className="h-full" />
|
|
</aside>
|
|
);
|
|
|
|
const hasDetails = !!(summary || hashtags.length > 0 || participants.length > 0);
|
|
const hasExpandable = hasDescription || hasDetails;
|
|
|
|
useLayoutOptions({ rightSidebar: chatSidebar, noBottomSpacer: true });
|
|
|
|
/** Details block — always visible on desktop, expandable on mobile.
|
|
* On mobile this also includes the author row.
|
|
*/
|
|
const detailsBlock = (
|
|
<div className="space-y-4">
|
|
{/* Author — mobile only (desktop shows it above) */}
|
|
<div className="xl:hidden">
|
|
<StreamAuthorRow event={event} participants={participants} />
|
|
</div>
|
|
|
|
{/* Summary */}
|
|
{summary && (
|
|
<p className="text-sm text-muted-foreground leading-relaxed">{summary}</p>
|
|
)}
|
|
|
|
{/* Hashtags */}
|
|
{hashtags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{hashtags.map((tag) => (
|
|
<Link
|
|
key={tag}
|
|
to={`/t/${encodeURIComponent(tag)}`}
|
|
className="text-sm text-primary hover:underline"
|
|
>
|
|
#{tag}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Participants list */}
|
|
{participants.length > 0 && (
|
|
<div className="space-y-2 pt-2 border-t border-border">
|
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
|
Participants
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{participants.map((p) => (
|
|
<ParticipantRow key={p.pubkey} pubkey={p.pubkey} role={p.role} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<main className="xl:min-h-screen max-sidebar:flex max-sidebar:flex-col max-sidebar:h-[calc(100dvh-6.5rem)] max-sidebar:max-h-[calc(100dvh-6.5rem)] max-sidebar:overflow-hidden">
|
|
{/* Header */}
|
|
<div className="shrink-0 sidebar:sticky sidebar:top-0 z-10 flex items-center gap-4 px-4 mt-4 mb-4 bg-background/80 backdrop-blur-md">
|
|
<button
|
|
onClick={() => navigate(-1)}
|
|
className="p-1.5 -ml-1.5 rounded-full hover:bg-secondary/60 transition-colors"
|
|
aria-label="Go back"
|
|
>
|
|
<ArrowLeft className="size-5" />
|
|
</button>
|
|
<h1 className="text-xl font-bold truncate">Live Stream</h1>
|
|
<Badge variant="outline" className={cn('ml-auto shrink-0', statusConfig.className)}>
|
|
{status === 'live' && <Radio className="size-3 mr-1" />}
|
|
{statusConfig.label}
|
|
</Badge>
|
|
</div>
|
|
|
|
{/* Video Player */}
|
|
<div className="px-4 shrink-0">
|
|
{playUrl ? (
|
|
<LiveStreamPlayer
|
|
src={playUrl}
|
|
poster={imageUrl}
|
|
className="w-full"
|
|
/>
|
|
) : (
|
|
<div className="aspect-video rounded-2xl bg-muted flex items-center justify-center border border-border">
|
|
<div className="text-center space-y-2">
|
|
<Radio className="size-8 text-muted-foreground/40 mx-auto" />
|
|
<p className="text-sm text-muted-foreground">
|
|
{status === 'planned' ? 'Stream has not started yet' : 'No stream URL available'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Stream compact info — always visible */}
|
|
<div className="px-4 mt-4 space-y-3 shrink-0">
|
|
{/* Title row with zap button on the right */}
|
|
<div className="flex items-start gap-3">
|
|
<div className="flex-1 min-w-0 space-y-2">
|
|
<h2 className="text-lg font-bold leading-snug">{title}</h2>
|
|
{/* Meta row */}
|
|
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">
|
|
{currentParticipants && (
|
|
<span className="flex items-center gap-1">
|
|
<Users className="size-3.5" />
|
|
{currentParticipants} watching
|
|
</span>
|
|
)}
|
|
{starts && (
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="size-3.5" />
|
|
{formatDateTime(parseInt(starts))}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{/* Zap button — right-aligned */}
|
|
{user && <ZapButton event={event} />}
|
|
</div>
|
|
|
|
{/* Author / Host — desktop only (on mobile it's inside the expandable details) */}
|
|
<div className="hidden xl:block">
|
|
<StreamAuthorRow event={event} participants={participants} />
|
|
</div>
|
|
|
|
{/* Mobile: expandable details toggle */}
|
|
{hasExpandable && (
|
|
<button
|
|
onClick={() => setDescExpanded((v) => !v)}
|
|
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors xl:hidden"
|
|
>
|
|
{descExpanded ? <ChevronUp className="size-3.5" /> : <ChevronDown className="size-3.5" />}
|
|
{descExpanded ? 'Hide details' : 'Show details'}
|
|
</button>
|
|
)}
|
|
|
|
{/* Mobile: collapsible details */}
|
|
{descExpanded && (
|
|
<div className="xl:hidden">
|
|
{detailsBlock}
|
|
</div>
|
|
)}
|
|
|
|
{/* Desktop: always show details */}
|
|
<div className="hidden xl:block">
|
|
{detailsBlock}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile chat — fills remaining viewport, scrollbox sits above bottom nav */}
|
|
<div className="xl:hidden mt-4 border-t border-border flex-1 min-h-0">
|
|
<LiveStreamChat aTag={aTag} className="h-full" />
|
|
</div>
|
|
|
|
{/* Bottom spacer (desktop only) */}
|
|
<div className="hidden xl:block h-8" />
|
|
</main>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function StreamAuthorRow({ event, participants }: { event: NostrEvent; participants: Participant[] }) {
|
|
const author = useAuthor(event.pubkey);
|
|
const metadata = author.data?.metadata;
|
|
const displayName = getDisplayName(metadata, event.pubkey);
|
|
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
|
|
|
// Find the host from participants or default to the event author
|
|
const host = participants.find((p) => p.role?.toLowerCase() === 'host');
|
|
const hostPubkey = host?.pubkey || event.pubkey;
|
|
|
|
const hostAuthor = useAuthor(hostPubkey);
|
|
const hostMetadata = hostAuthor.data?.metadata;
|
|
const hostName = getDisplayName(hostMetadata, hostPubkey);
|
|
const hostProfileUrl = useProfileUrl(hostPubkey, hostMetadata);
|
|
|
|
// Use the host if different from the event author
|
|
const showPubkey = hostPubkey;
|
|
const showName = hostPubkey === event.pubkey ? displayName : hostName;
|
|
const showMetadata = hostPubkey === event.pubkey ? metadata : hostMetadata;
|
|
const showProfileUrl = hostPubkey === event.pubkey ? profileUrl : hostProfileUrl;
|
|
const showAuthor = hostPubkey === event.pubkey ? author : hostAuthor;
|
|
|
|
if (showAuthor.isLoading) {
|
|
return (
|
|
<div className="flex items-center gap-3">
|
|
<Skeleton className="size-10 rounded-full shrink-0" />
|
|
<div className="space-y-1">
|
|
<Skeleton className="h-4 w-28" />
|
|
<Skeleton className="h-3 w-20" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-3">
|
|
<ProfileHoverCard pubkey={showPubkey} asChild>
|
|
<Link to={showProfileUrl}>
|
|
<Avatar className="size-10">
|
|
<AvatarImage src={showMetadata?.picture} alt={showName} />
|
|
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
|
{showName[0]?.toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
</Link>
|
|
</ProfileHoverCard>
|
|
<div className="min-w-0">
|
|
<ProfileHoverCard pubkey={showPubkey} asChild>
|
|
<Link to={showProfileUrl} className="font-semibold text-sm hover:underline block truncate">
|
|
{showAuthor.data?.event ? (
|
|
<EmojifiedText tags={showAuthor.data.event.tags}>{showName}</EmojifiedText>
|
|
) : showName}
|
|
</Link>
|
|
</ProfileHoverCard>
|
|
<div className="flex items-center gap-1.5">
|
|
{showMetadata?.nip05 && (
|
|
<Nip05Badge nip05={showMetadata.nip05} pubkey={showPubkey} className="text-xs text-muted-foreground" iconSize={12} />
|
|
)}
|
|
{host && (
|
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">Host</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ZapButton({ event }: { event: NostrEvent }) {
|
|
const author = useAuthor(event.pubkey);
|
|
const metadata = author.data?.metadata;
|
|
|
|
if (!canZap(metadata)) return null;
|
|
|
|
return (
|
|
<ZapDialog target={event}>
|
|
<Button variant="outline" size="icon" className="shrink-0 size-9 rounded-full text-amber-500 hover:text-amber-400 hover:bg-amber-500/10">
|
|
<Zap className="size-4" />
|
|
</Button>
|
|
</ZapDialog>
|
|
);
|
|
}
|
|
|
|
function ParticipantRow({ pubkey, role }: { pubkey: string; role?: string }) {
|
|
const author = useAuthor(pubkey);
|
|
const metadata = author.data?.metadata;
|
|
const displayName = getDisplayName(metadata, pubkey);
|
|
const profileUrl = useProfileUrl(pubkey, metadata);
|
|
|
|
return (
|
|
<div className="flex items-center gap-2.5">
|
|
<Link to={profileUrl} className="shrink-0">
|
|
<Avatar className="size-7">
|
|
<AvatarImage src={metadata?.picture} alt={displayName} />
|
|
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
|
{displayName[0]?.toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
</Link>
|
|
<Link to={profileUrl} className="text-sm font-medium hover:underline truncate">
|
|
{author.data?.event ? (
|
|
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
|
|
) : displayName}
|
|
</Link>
|
|
{role && (
|
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 shrink-0 ml-auto">
|
|
{role}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|