Spaces:
Sleeping
Sleeping
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<HTMLDivElement>; | |
}) { | |
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 = ( | |
<div key={`text-${m._id}`} className="leading-tight mb-6"> | |
<div className="flex gap-4"> | |
<span className="uppercase flex-grow">{m.authorName}</span> | |
<time dateTime={m._creationTime.toString()}> | |
{new Date(m._creationTime).toLocaleString()} | |
</time> | |
</div> | |
<div className={clsx('bubble', m.author === humanPlayerId && 'bubble-mine')}> | |
<p className="bg-white -mx-3 -my-1">{m.text}</p> | |
</div> | |
</div> | |
); | |
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: ( | |
<div key={`joined-${playerId}`} className="leading-tight mb-6"> | |
<p className="text-brown-700 text-center">{playerName} joined the conversation.</p> | |
</div> | |
), | |
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: ( | |
<div key={`joined-${playerId}`} className="leading-tight mb-6"> | |
<p className="text-brown-700 text-center">{playerName} joined the conversation.</p> | |
</div> | |
), | |
time: started, | |
}); | |
const ended = conversation.doc.ended; | |
membershipNodes.push({ | |
node: ( | |
<div key={`left-${playerId}`} className="leading-tight mb-6"> | |
<p className="text-brown-700 text-center">{playerName} left the conversation.</p> | |
</div> | |
), | |
// 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 ( | |
<div className="chats text-base sm:text-sm"> | |
<div className="bg-brown-200 text-black p-2"> | |
{nodes.length > 0 && nodes.map((n) => n.node)} | |
{currentlyTyping && currentlyTyping.playerId !== humanPlayerId && ( | |
<div key="typing" className="leading-tight mb-6"> | |
<div className="flex gap-4"> | |
<span className="uppercase flex-grow">{currentlyTypingName}</span> | |
<time dateTime={currentlyTyping.since.toString()}> | |
{new Date(currentlyTyping.since).toLocaleString()} | |
</time> | |
</div> | |
<div className={clsx('bubble')}> | |
<p className="bg-white -mx-3 -my-1"> | |
<i>typing...</i> | |
</p> | |
</div> | |
</div> | |
)} | |
{humanPlayer && inConversationWithMe && conversation.kind === 'active' && ( | |
<MessageInput | |
worldId={worldId} | |
engineId={engineId} | |
conversation={conversation.doc} | |
humanPlayer={humanPlayer} | |
/> | |
)} | |
</div> | |
</div> | |
); | |
} | |