Files
eranos/src/components/LiveStreamChat.tsx
T
Chad Curtis 5bbbc5022a Fix mobile live stream layout: viewport-locked chat above bottom nav
Lock main element to viewport height on mobile with body scroll disabled,
collapsible description with author under details, zap button inline with
title, and chat input sitting directly above the bottom nav with no gaps.
2026-02-20 15:14:05 -06:00

228 lines
7.9 KiB
TypeScript

import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { Send, MessageCircle } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { getDisplayName } from '@/lib/getDisplayName';
import { getProfileUrl } from '@/lib/profileUrl';
import { cn } from '@/lib/utils';
interface LiveStreamChatProps {
/** The `a` tag value: `30311:<pubkey>:<d-tag>` */
aTag: string;
className?: string;
}
/** Format seconds-ago into a short time string. */
function shortTimeAgo(timestamp: number): string {
const diff = Math.floor(Date.now() / 1000) - timestamp;
if (diff < 60) return 'now';
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
return `${Math.floor(diff / 86400)}d`;
}
export function LiveStreamChat({ aTag, className }: LiveStreamChatProps) {
const { nostr } = useNostr();
const queryClient = useQueryClient();
const { user } = useCurrentUser();
const { mutateAsync: createEvent, isPending: isSending } = useNostrPublish();
const [message, setMessage] = useState('');
const scrollRef = useRef<HTMLDivElement>(null);
const isAutoScrollRef = useRef(true);
// Fetch existing chat messages
const { data: messages = [], isLoading } = useQuery<NostrEvent[]>({
queryKey: ['live-chat', aTag],
queryFn: async ({ signal }) => {
const events = await nostr.query(
[{ kinds: [1311], '#a': [aTag], limit: 200 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
);
return events.sort((a, b) => a.created_at - b.created_at);
},
staleTime: 10_000,
refetchInterval: 5_000,
});
// Subscribe to new messages in real-time
useEffect(() => {
const controller = new AbortController();
(async () => {
try {
for await (const msg of nostr.req(
[{ kinds: [1311], '#a': [aTag], since: Math.floor(Date.now() / 1000) }],
{ signal: controller.signal },
)) {
if (msg[0] === 'EVENT') {
const event = msg[2] as NostrEvent;
queryClient.setQueryData<NostrEvent[]>(['live-chat', aTag], (old = []) => {
if (old.some(e => e.id === event.id)) return old;
return [...old, event].sort((a, b) => a.created_at - b.created_at);
});
}
}
} catch {
// Subscription ended
}
})();
return () => controller.abort();
}, [nostr, aTag, queryClient]);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
if (isAutoScrollRef.current && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
isAutoScrollRef.current = isAtBottom;
}, []);
const handleSend = async () => {
const text = message.trim();
if (!text || !user || isSending) return;
try {
await createEvent({
kind: 1311,
content: text,
tags: [['a', aTag, '', 'root']],
});
setMessage('');
} catch {
// Error handled by mutation
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className={cn('flex flex-col overflow-hidden', className)}>
{/* Chat header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border shrink-0">
<MessageCircle className="size-4 text-muted-foreground" />
<span className="text-sm font-semibold">Live Chat</span>
<span className="text-xs text-muted-foreground ml-auto tabular-nums">
{messages.length} message{messages.length !== 1 ? 's' : ''}
</span>
</div>
{/* Messages area — scrollable, takes remaining space */}
<div
ref={scrollRef}
className="flex-1 min-h-0 overflow-y-auto p-3 space-y-1"
onScroll={handleScroll}
>
{isLoading ? (
<div className="space-y-3 p-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-start gap-2">
<Skeleton className="size-6 rounded-full shrink-0" />
<div className="space-y-1 flex-1">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-3/4" />
</div>
</div>
))}
</div>
) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<MessageCircle className="size-8 text-muted-foreground/40 mb-2" />
<p className="text-sm text-muted-foreground">No messages yet</p>
<p className="text-xs text-muted-foreground/60 mt-1">Be the first to say something!</p>
</div>
) : (
messages.map((msg) => (
<ChatMessage key={msg.id} event={msg} />
))
)}
</div>
{/* Input area */}
<div className="border-t border-border p-3 shrink-0">
{user ? (
<div className="flex gap-2">
<Input
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Say something..."
className="flex-1 h-9 text-sm"
disabled={isSending}
maxLength={500}
/>
<Button
size="sm"
onClick={handleSend}
disabled={!message.trim() || isSending}
className="h-9 px-3"
>
<Send className="size-4" />
</Button>
</div>
) : (
<p className="text-xs text-muted-foreground text-center py-1">
Log in to participate in the chat
</p>
)}
</div>
</div>
);
}
function ChatMessage({ event }: { event: NostrEvent }) {
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const displayName = getDisplayName(metadata, event.pubkey);
const profileUrl = useMemo(() => getProfileUrl(event.pubkey, metadata), [event.pubkey, metadata]);
return (
<div className="group flex items-start gap-2 py-1 px-1 rounded hover:bg-secondary/40 transition-colors">
<Link to={profileUrl} className="shrink-0 mt-0.5" onClick={(e) => e.stopPropagation()}>
<Avatar className="size-6">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[9px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
<div className="flex-1 min-w-0">
<span className="inline">
<Link
to={profileUrl}
className="text-xs font-semibold text-primary hover:underline mr-1.5"
onClick={(e) => e.stopPropagation()}
>
{displayName}
</Link>
<span className="text-sm text-foreground break-words">{event.content}</span>
</span>
<span className="text-[10px] text-muted-foreground/60 ml-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
{shortTimeAgo(event.created_at)}
</span>
</div>
</div>
);
}