import React, { useState, useRef, useEffect } from 'react'
import ReactMarkdown from 'react-markdown'
const QUICK_REACTIONS = ['๐', 'โค๏ธ', '๐ฅ', '๐', '๐', '๐ฎ']
/**
* Individual message bubble with:
* - Markdown rendering (no raw HTML allowed)
* - Hover reaction picker + unreact on click
* - Inline edit for own messages
* - Soft-delete display
*/
export default function MessageBubble({ message, isMine, showAvatar, onReact, onUnreact, onEdit, onReport = null, seenText = null }) {
const [showPicker, setShowPicker] = useState(false)
const [editing, setEditing] = useState(false)
const [editBody, setEditBody] = useState(message.body ?? '')
const [savingEdit, setSavingEdit] = useState(false)
const editRef = useRef(null)
const isDeleted = !!message.deleted_at
const isEdited = !!message.edited_at
const username = message.sender?.username ?? 'Unknown'
const time = formatTime(message.created_at)
// Focus textarea when entering edit mode
useEffect(() => {
if (editing) {
editRef.current?.focus()
editRef.current?.setSelectionRange(editBody.length, editBody.length)
}
}, [editing])
const reactionGroups = groupReactions(message.reactions ?? [])
const handleSaveEdit = async () => {
const trimmed = editBody.trim()
if (!trimmed || trimmed === message.body || savingEdit) return
setSavingEdit(true)
try {
await onEdit(message.id, trimmed)
setEditing(false)
} finally {
setSavingEdit(false)
}
}
const handleEditKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSaveEdit() }
if (e.key === 'Escape') { setEditing(false); setEditBody(message.body) }
}
return (
!isDeleted && !editing && setShowPicker(true)}
onMouseLeave={() => setShowPicker(false)}
>
{/* Avatar */}
{username.charAt(0).toUpperCase()}
{/* Sender name & time */}
{showAvatar && (
{username}
{time}
)}
{/* Bubble */}
{editing ? (
) : (
{isDeleted ? (
This message was deleted.
) : (
<>
{message.attachments?.length > 0 && (
{message.attachments.map(att => (
))}
)}
(
{children}
),
code: ({ children, className }) => className
? {children}
: {children},
}}
>
{message.body}
{isEdited && (
(edited)
)}
>
)}
)}
{/* Hover action strip: reactions + edit pencil */}
{showPicker && !editing && !isDeleted && (
setShowPicker(true)}
onMouseLeave={() => setShowPicker(false)}
>
{QUICK_REACTIONS.map(emoji => (
))}
{isMine && (
)}
{!isMine && onReport && (
)}
)}
{/* Reactions bar */}
{reactionGroups.length > 0 && !isDeleted && (
{reactionGroups.map(({ emoji, count, iReacted }) => (
))}
)}
{isMine && seenText && (
{seenText}
)}
)
}
function formatTime(iso) {
return new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
}
function groupReactions(reactions) {
const map = new Map()
for (const r of reactions) {
if (!map.has(r.reaction)) map.set(r.reaction, { count: 0, iReacted: false })
const entry = map.get(r.reaction)
entry.count++
if (r._iMine) entry.iReacted = true
}
return Array.from(map.entries()).map(([emoji, { count, iReacted }]) => ({ emoji, count, iReacted }))
}