/** * MessageList -- Self-contained chat panel (feed + composer + thread panel). * Do not render a separate MessageInput alongside this component. */ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useMessages, isWriterRole } from 'deepspace' import { useReactions } from 'deepspace' import { useUser } from 'deepspace' import type { Message } from 'deepspace' import type { RecordData } from 'deepspace' import { MessageItem } from './MessageItem' import type { MessageRect } from './MessageItem' import { MessageInput } from './MessageInput' import { MessageActionSheet } from './MessageActionSheet' import { ThreadPanel } from './ThreadPanel' interface MessageListProps { channelId: string } interface ActionSheetState { messageId: string content: string isOwn: boolean rect: MessageRect } const GROUP_THRESHOLD_MS = 5 * 60 * 1000 const TIME_SEPARATOR_GAP_MS = 60 * 60 * 1000 function getAuthorId(msg: RecordData): string { return msg.data.authorId || msg.createdBy } function isFirstInGroup(msgs: RecordData[], i: number): boolean { if (i === 0) return true const prev = msgs[i - 1] const curr = msgs[i] if (getAuthorId(prev) !== getAuthorId(curr)) return true return ( new Date(curr.createdAt).getTime() - new Date(prev.createdAt).getTime() >= GROUP_THRESHOLD_MS ) } function formatTime(date: Date): string { return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) } function getSeparatorLabel(date: Date): string { const today = new Date() const yesterday = new Date(today) yesterday.setDate(yesterday.getDate() - 1) if (date.toDateString() === today.toDateString()) return `Today ${formatTime(date)}` if (date.toDateString() === yesterday.toDateString()) return `Yesterday ${formatTime(date)}` return ( date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', }) + ` ${formatTime(date)}` ) } function shouldShowSeparator( current: RecordData, prev: RecordData | undefined, ): boolean { if (!prev) return true const curDate = new Date(current.createdAt) const prevDate = new Date(prev.createdAt) if (curDate.toDateString() !== prevDate.toDateString()) return true return curDate.getTime() - prevDate.getTime() >= TIME_SEPARATOR_GAP_MS } export function MessageList({ channelId }: MessageListProps) { const { messages, status, send, edit, softDelete } = useMessages(channelId) const { getReactionsForMessage, toggle: toggleReaction } = useReactions(channelId) const { user: currentUser } = useUser() const canWrite = isWriterRole(currentUser?.role) const bottomRef = useRef(null) const feedRef = useRef(null) const isInitialMount = useRef(true) const prevMessageCount = useRef(0) const [actionSheet, setActionSheet] = useState(null) const [editingMessageId, setEditingMessageId] = useState(null) const [threadMessageId, setThreadMessageId] = useState(null) const topLevelMessages = useMemo( () => messages .filter((m: RecordData) => !m.data.parentMessageId) .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()), [messages], ) const threadCounts = useMemo(() => { const counts = new Map() for (const m of messages) { if (m.data.parentMessageId) { counts.set(m.data.parentMessageId, (counts.get(m.data.parentMessageId) ?? 0) + 1) } } return counts }, [messages]) useEffect(() => { const count = topLevelMessages.length const wasNewMessage = count > prevMessageCount.current prevMessageCount.current = count if (count === 0) return if (isInitialMount.current) { isInitialMount.current = false bottomRef.current?.scrollIntoView({ behavior: 'auto' }) return } if (!wasNewMessage) return const feed = feedRef.current if (!feed) return const distanceFromBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight const NEAR_BOTTOM_THRESHOLD = 150 if (distanceFromBottom <= NEAR_BOTTOM_THRESHOLD) { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) } }, [topLevelMessages.length]) const handleLongPress = useCallback( (msg: RecordData, rect: MessageRect) => { if (!canWrite) return const authorId = msg.data.authorId || msg.createdBy const isOwn = currentUser?.id === authorId || currentUser?.id === msg.createdBy setActionSheet({ messageId: msg.recordId, content: msg.data.content, isOwn, rect, }) }, [canWrite, currentUser], ) if (status === 'loading') { return (
Loading messages...
) } if (status === 'error') { return (

Failed to load messages

Check your connection and try again

) } const threadOpen = !!threadMessageId return (
{topLevelMessages.length === 0 ? (

No messages yet

{canWrite ? 'Be the first to send a message!' : 'There are no messages yet.'}

) : (
{topLevelMessages.map((msg: RecordData, i: number) => { const prev = i > 0 ? topLevelMessages[i - 1] : undefined const showSeparator = shouldShowSeparator(msg, prev) const firstInGroup = showSeparator || isFirstInGroup(topLevelMessages, i) return (
{showSeparator && (
{getSeparatorLabel(new Date(msg.createdAt))}
)} toggleReaction(msg.recordId, emoji)} onOpenThread={() => setThreadMessageId(msg.recordId)} onEdit={(newContent) => edit(msg.recordId, newContent)} onDelete={() => softDelete(msg.recordId)} isFirstInGroup={firstInGroup} onLongPress={(rect) => handleLongPress(msg, rect)} forceEdit={editingMessageId === msg.recordId} onEditDone={() => setEditingMessageId(null)} isHighlighted={actionSheet?.messageId === msg.recordId} />
) })}
)}
{canWrite && ( send(content)} placeholder="Type a message..." /> )}
{threadMessageId && ( setThreadMessageId(null)} /> )} {canWrite && ( setActionSheet(null)} isOwn={actionSheet?.isOwn ?? false} onReaction={(emoji) => { if (actionSheet) toggleReaction(actionSheet.messageId, emoji) }} onEdit={() => { if (actionSheet) setEditingMessageId(actionSheet.messageId) }} onDelete={() => { if (actionSheet) softDelete(actionSheet.messageId) }} onReply={() => { if (actionSheet) setThreadMessageId(actionSheet.messageId) }} messageContent={actionSheet?.content ?? ''} messageRect={actionSheet?.rect ?? null} /> )}
) }