import clsx from 'clsx'; import { Doc, Id } from '../../convex/_generated/dataModel'; import { useQuery } from 'convex/react'; import { api } from '../../convex/_generated/api'; import { MessageInput } from './MessageInput'; import { Player } from '../../convex/aiTown/player'; import { Conversation } from '../../convex/aiTown/conversation'; import { useEffect, useRef } from 'react'; export function Messages({ worldId, engineId, conversation, inConversationWithMe, humanPlayer, scrollViewRef, }: { worldId: Id<'worlds'>; engineId: Id<'engines'>; conversation: | { kind: 'active'; doc: Conversation } | { kind: 'archived'; doc: Doc<'archivedConversations'> }; inConversationWithMe: boolean; humanPlayer?: Player; scrollViewRef: React.RefObject; }) { const humanPlayerId = humanPlayer?.id; const descriptions = useQuery(api.world.gameDescriptions, { worldId }); const messages = useQuery(api.messages.listMessages, { worldId, conversationId: conversation.doc.id, }); let currentlyTyping = conversation.kind === 'active' ? conversation.doc.isTyping : undefined; if (messages !== undefined && currentlyTyping) { if (messages.find((m) => m.messageUuid === currentlyTyping!.messageUuid)) { currentlyTyping = undefined; } } const currentlyTypingName = currentlyTyping && descriptions?.playerDescriptions.find((p) => p.playerId === currentlyTyping?.playerId)?.name; const scrollView = scrollViewRef.current; const isScrolledToBottom = useRef(false); useEffect(() => { if (!scrollView) return undefined; const onScroll = () => { isScrolledToBottom.current = !!( scrollView && scrollView.scrollHeight - scrollView.scrollTop - 50 <= scrollView.clientHeight ); }; scrollView.addEventListener('scroll', onScroll); return () => scrollView.removeEventListener('scroll', onScroll); }, [scrollView]); useEffect(() => { if (isScrolledToBottom.current) { scrollViewRef.current?.scrollTo({ top: scrollViewRef.current.scrollHeight, behavior: 'smooth', }); } }, [messages, currentlyTyping]); if (messages === undefined) { return null; } if (messages.length === 0 && !inConversationWithMe) { return null; } const messageNodes: { time: number; node: React.ReactNode }[] = messages.map((m) => { const node = (
{m.authorName}

{m.text}

); return { node, time: m._creationTime }; }); const lastMessageTs = messages.map((m) => m._creationTime).reduce((a, b) => Math.max(a, b), 0); const membershipNodes: typeof messageNodes = []; if (conversation.kind === 'active') { for (const [playerId, m] of conversation.doc.participants) { const playerName = descriptions?.playerDescriptions.find((p) => p.playerId === playerId) ?.name; let started; if (m.status.kind === 'participating') { started = m.status.started; } if (started) { membershipNodes.push({ node: (

{playerName} joined the conversation.

), time: started, }); } } } else { for (const playerId of conversation.doc.participants) { const playerName = descriptions?.playerDescriptions.find((p) => p.playerId === playerId) ?.name; const started = conversation.doc.created; membershipNodes.push({ node: (

{playerName} joined the conversation.

), time: started, }); const ended = conversation.doc.ended; membershipNodes.push({ node: (

{playerName} left the conversation.

), // Always sort all "left" messages after the last message. // TODO: We can remove this once we want to support more than two participants per conversation. time: Math.max(lastMessageTs + 1, ended), }); } } const nodes = [...messageNodes, ...membershipNodes]; nodes.sort((a, b) => a.time - b.time); return (
{nodes.length > 0 && nodes.map((n) => n.node)} {currentlyTyping && currentlyTyping.playerId !== humanPlayerId && (
{currentlyTypingName}

typing...

)} {humanPlayer && inConversationWithMe && conversation.kind === 'active' && ( )}
); }