import React, { useState, useRef, useEffect } from 'react'
import ReactMarkdown from 'react-markdown'
const QUICK_REACTIONS = ['๐', 'โค๏ธ', '๐ฅ', '๐', '๐', '๐ฎ']
export default function MessageBubble({ message, isMine, showAvatar, endsSequence = true, isNewlyArrived = false, prefersReducedMotion = false, onReact, onUnreact, onEdit, onDelete = null, onReport = null, onOpenImage = null, seenText = null }) {
const [showPicker, setShowPicker] = useState(false)
const [showActions, setShowActions] = useState(false)
const [editing, setEditing] = useState(false)
const [editBody, setEditBody] = useState(message.body ?? '')
const [savingEdit, setSavingEdit] = useState(false)
const [isArrivalVisible, setIsArrivalVisible] = useState(true)
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)
const initials = username.charAt(0).toUpperCase()
useEffect(() => {
if (editing) {
editRef.current?.focus()
editRef.current?.setSelectionRange(editBody.length, editBody.length)
}
}, [editing, editBody.length])
useEffect(() => {
if (prefersReducedMotion || !isNewlyArrived) {
setIsArrivalVisible(true)
return undefined
}
setIsArrivalVisible(false)
const frame = window.requestAnimationFrame(() => setIsArrivalVisible(true))
return () => window.cancelAnimationFrame(frame)
}, [isNewlyArrived, prefersReducedMotion])
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)
setShowActions(false)
}}
>
{showAvatar ? (
{username}
{time}
) : null}
{!editing && !isDeleted ? (
) : null}
{editing ? (
) : (
{isDeleted ? (
This message was deleted.
) : (
<>
{message.attachments?.length > 0 ? (
) : null}
(
{children}
),
code: ({ children, className }) => className
? {children}
: {children},
}}
>
{message.body}
{isEdited ? (
(edited)
) : null}
>
)}
)}
{(showPicker || showActions) && !editing && !isDeleted ? (
setShowPicker(true)}
onMouseLeave={() => {
setShowPicker(false)
setShowActions(false)
}}
>
{QUICK_REACTIONS.map((emoji) => (
))}
{isMine ? (
<>
{onDelete ? (
) : null}
>
) : null}
{!isMine && onReport ? (
) : null}
) : null}
{reactionGroups.length > 0 && !isDeleted ? (
{reactionGroups.map(({ emoji, count, iReacted }) => (
))}
) : null}
{isMine && seenText ? (
{seenText}
) : null}
)
}
function formatTime(iso) {
return new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
}
function bubbleShape({ isMine, showAvatar, endsSequence }) {
if (isMine) {
return `${showAvatar ? 'rounded-t-[24px]' : 'rounded-tl-[24px] rounded-tr-[14px]'} ${endsSequence ? 'rounded-bl-[24px] rounded-br-[8px]' : 'rounded-bl-[24px] rounded-br-[14px]'}`
}
return `${showAvatar ? 'rounded-t-[24px]' : 'rounded-tr-[24px] rounded-tl-[14px]'} ${endsSequence ? 'rounded-br-[24px] rounded-bl-[8px]' : 'rounded-br-[24px] rounded-bl-[14px]'}`
}
function AttachmentCluster({ attachments, isMine, onOpenImage }) {
const images = attachments.filter((attachment) => attachment.type === 'image')
const files = attachments.filter((attachment) => attachment.type !== 'image')
return (
{images.length > 0 ?
: null}
{files.length > 0 ?
: null}
)
}
function AttachmentImageGrid({ images, onOpenImage }) {
const gridClass = images.length === 1 ? 'grid-cols-1' : 'grid-cols-2'
return (
{images.slice(0, 4).map((attachment, index) => {
const remaining = images.length - 4
const showOverlay = remaining > 0 && index === 3
return (
)
})}
)
}
function AttachmentFileStack({ files, isMine }) {
return (
)
}
function groupReactions(reactions) {
const map = new Map()
for (const reaction of reactions) {
if (!map.has(reaction.reaction)) map.set(reaction.reaction, { count: 0, iReacted: false })
const entry = map.get(reaction.reaction)
entry.count += 1
if (reaction._iMine) entry.iReacted = true
}
return Array.from(map.entries()).map(([emoji, { count, iReacted }]) => ({ emoji, count, iReacted }))
}