/** * MessageItem -- Single message row with avatar, grouped timestamps, * desktop hover toolbar, mobile long-press delegation, thread reply count, * presence indicator, and user profile popover. */ import { useCallback, useEffect, useRef, useState } from 'react' import { useUserLookup, isWriterRole } from 'deepspace' import { useUser } from 'deepspace' import { usePresence } from 'deepspace' import type { Message } from 'deepspace' import type { RecordData } from 'deepspace' import type { GroupedReaction } from 'deepspace' import { UserProfilePopover } from './UserProfilePopover' import { useLongPress } from '../hooks/useLongPress' const QUICK_EMOJIS = [ '\u{1F44D}', '\u2764\uFE0F', '\u{1F602}', '\u{1F389}', '\u{1F525}', '\u{1F440}', ] export interface MessageRect { top: number left: number width: number height: number } interface MessageItemProps { message: RecordData reactions: GroupedReaction[] threadCount: number onToggleReaction: (emoji: string) => void onOpenThread: () => void onEdit: (newContent: string) => void onDelete: () => void isFirstInGroup: boolean onLongPress?: (rect: MessageRect) => void forceEdit?: boolean onEditDone?: () => void isHighlighted?: boolean } export function MessageItem({ message, reactions, threadCount, onToggleReaction, onOpenThread, onEdit, onDelete, isFirstInGroup, onLongPress, forceEdit = false, onEditDone, isHighlighted = false, }: MessageItemProps) { const { getUser } = useUserLookup() const { user: currentUser } = useUser() const { isOnline } = usePresence() const [isEditing, setIsEditing] = useState(false) const [editContent, setEditContent] = useState(message.data.content) const editContainerRef = useRef(null) const contentRef = useRef(null) const latestMessageContentRef = useRef(message.data.content) latestMessageContentRef.current = message.data.content const authorId = message.data.authorId || message.createdBy const author = getUser(authorId) const isOwn = currentUser?.id === authorId || currentUser?.id === message.createdBy const canWrite = isWriterRole(currentUser?.role) const online = isOnline(authorId) const longPressHandlers = useLongPress( () => { if (!canWrite || !contentRef.current) return const rect = contentRef.current.getBoundingClientRect() onLongPress?.({ top: rect.top, left: rect.left, width: rect.width, height: rect.height, }) }, { delay: 500, threshold: 10 }, ) useEffect(() => { if (!forceEdit || isEditing) return setEditContent(latestMessageContentRef.current) setIsEditing(true) }, [forceEdit, isEditing]) const timestamp = new Date(message.createdAt) const timeStr = timestamp.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) const dateTimeStr = timestamp.toLocaleDateString(undefined, { month: '2-digit', day: '2-digit', year: '2-digit', }) + ', ' + timeStr const exitEdit = useCallback(() => { setIsEditing(false) onEditDone?.() }, [onEditDone]) const handleSaveEdit = useCallback(() => { const trimmed = editContent.trim() if (trimmed && trimmed !== message.data.content) { onEdit(trimmed) } exitEdit() }, [editContent, message.data.content, onEdit, exitEdit]) const handleCancelEdit = useCallback(() => { setEditContent(message.data.content) exitEdit() }, [message.data.content, exitEdit]) useEffect(() => { if (!isEditing) return function handleClickOutside(e: MouseEvent) { if (editContainerRef.current && !editContainerRef.current.contains(e.target as Node)) { setEditContent(message.data.content) exitEdit() } } const timer = setTimeout(() => { document.addEventListener('mousedown', handleClickOutside) }, 0) return () => { clearTimeout(timer) document.removeEventListener('mousedown', handleClickOutside) } }, [isEditing, message.data.content, exitEdit]) const handleEditKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleSaveEdit() } if (e.key === 'Escape') { handleCancelEdit() } } const avatar = (
{author?.imageUrl ? ( ) : (
{(author?.name ?? '?')[0]?.toUpperCase()}
)}
) // Soft-deleted messages render as a tombstone — the row stays so threads, // read-receipts, and parent links remain coherent, but no content/actions. if (message.data.deleted) { return (

This message was deleted.

) } return (
{/* Avatar column */}
{isFirstInGroup ? ( {avatar} ) : ( {timeStr} )}
{/* Content column */}
{isFirstInGroup && (
{author?.name ?? 'Unknown'} {dateTimeStr}
)} {isEditing ? (