import React, { useState, useEffect, useRef, useCallback } from 'react' import { Send, Trash2, Reply, ChevronUp, MessageCircle } from 'lucide-react' import { collabApi } from '../../api/client' import { addListener, removeListener } from '../../api/websocket' import { useTranslation } from '../../i18n' /* ───────── relative timestamp ───────── */ function formatRelativeTime(isoString, t) { const now = Date.now() const then = new Date(isoString).getTime() const diff = now - then const seconds = Math.floor(diff / 1000) const minutes = Math.floor(seconds / 60) const hours = Math.floor(minutes / 60) if (seconds < 60) return t('collab.chat.justNow') if (minutes < 60) return t('collab.chat.minutesAgo', { n: minutes }) if (hours < 24) return t('collab.chat.hoursAgo', { n: hours }) const d = new Date(isoString) const today = new Date() const yesterday = new Date() yesterday.setDate(today.getDate() - 1) if ( d.getDate() === yesterday.getDate() && d.getMonth() === yesterday.getMonth() && d.getFullYear() === yesterday.getFullYear() ) { const hh = String(d.getHours()).padStart(2, '0') const mm = String(d.getMinutes()).padStart(2, '0') return `yesterday ${hh}:${mm}` } const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'] return `${monthNames[d.getMonth()]} ${d.getDate()}` } /* ─────────────────────────────────────── */ /* Component */ /* ─────────────────────────────────────── */ export default function CollabChat({ tripId, currentUser }) { const { t } = useTranslation() const [messages, setMessages] = useState([]) const [loading, setLoading] = useState(true) const [hasMore, setHasMore] = useState(false) const [loadingMore, setLoadingMore] = useState(false) const [text, setText] = useState('') const [replyTo, setReplyTo] = useState(null) const [hoveredId, setHoveredId] = useState(null) const [sending, setSending] = useState(false) const scrollRef = useRef(null) const textareaRef = useRef(null) const isAtBottom = useRef(true) /* ── scroll helpers ── */ const scrollToBottom = useCallback((behavior = 'auto') => { const el = scrollRef.current if (!el) return requestAnimationFrame(() => { el.scrollTo({ top: el.scrollHeight, behavior }) }) }, []) const checkAtBottom = useCallback(() => { const el = scrollRef.current if (!el) return isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48 }, []) /* ── load messages ── */ useEffect(() => { let cancelled = false setLoading(true) collabApi.getMessages(tripId).then(data => { if (cancelled) return const msgs = Array.isArray(data) ? data : data.messages || [] setMessages(msgs) setHasMore(msgs.length >= 100) setLoading(false) setTimeout(() => scrollToBottom(), 30) }).catch(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true } }, [tripId, scrollToBottom]) /* ── load more (older messages) ── */ const handleLoadMore = useCallback(async () => { if (loadingMore || messages.length === 0) return setLoadingMore(true) const el = scrollRef.current const prevHeight = el ? el.scrollHeight : 0 try { const oldestId = messages[0]?.id const data = await collabApi.getMessages(tripId, oldestId) const older = Array.isArray(data) ? data : data.messages || [] if (older.length === 0) { setHasMore(false) } else { setMessages(prev => [...older, ...prev]) setHasMore(older.length >= 100) requestAnimationFrame(() => { if (el) el.scrollTop = el.scrollHeight - prevHeight }) } } catch { // silently ignore } finally { setLoadingMore(false) } }, [tripId, loadingMore, messages]) /* ── websocket ── */ useEffect(() => { const handler = (event) => { if (event.type === 'collab:message:created' && String(event.tripId) === String(tripId)) { setMessages(prev => { if (prev.some(m => m.id === event.message.id)) return prev return [...prev, event.message] }) if (isAtBottom.current) { setTimeout(() => scrollToBottom('smooth'), 30) } } if (event.type === 'collab:message:deleted' && String(event.tripId) === String(tripId)) { setMessages(prev => prev.filter(m => m.id !== event.messageId)) } } addListener(handler) return () => removeListener(handler) }, [tripId, scrollToBottom]) /* ── auto-resize textarea ── */ const handleTextChange = useCallback((e) => { setText(e.target.value) const ta = textareaRef.current if (ta) { ta.style.height = 'auto' ta.style.height = Math.min(ta.scrollHeight, 3 * 20 + 18) + 'px' } }, []) /* ── send ── */ const handleSend = useCallback(async () => { const body = text.trim() if (!body || sending) return setSending(true) try { const payload = { text: body } if (replyTo) payload.reply_to = replyTo.id const data = await collabApi.sendMessage(tripId, payload) if (data?.message) { setMessages(prev => { if (prev.some(m => m.id === data.message.id)) return prev return [...prev, data.message] }) } setText('') setReplyTo(null) if (textareaRef.current) { textareaRef.current.style.height = 'auto' } scrollToBottom('smooth') } catch { // keep text on failure so user can retry } finally { setSending(false) } }, [text, sending, replyTo, tripId, scrollToBottom]) /* ── keyboard ── */ const handleKeyDown = useCallback((e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleSend() } }, [handleSend]) /* ── delete ── */ const handleDelete = useCallback(async (msgId) => { try { await collabApi.deleteMessage(tripId, msgId) } catch { // ignore } }, [tripId]) /* ── find a replied-to message ── */ const findMessage = useCallback((id) => messages.find(m => m.id === id), [messages]) /* ── helpers ── */ const isOwn = (msg) => String(msg.user_id) === String(currentUser.id) const font = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" /* ───────── render: loading ───────── */ if (loading) { return (
) } /* ───────── render: main ───────── */ return (
{/* ── messages area ── */} {messages.length === 0 ? (
{t('collab.chat.empty')} {t('collab.chat.emptyDesc') || ''}
) : (
{/* load more */} {hasMore && (
)} {messages.map((msg, idx) => { const own = isOwn(msg) const repliedMsg = msg.reply_to_id ? findMessage(msg.reply_to_id) : null const prevMsg = messages[idx - 1] const isNewGroup = idx === 0 || String(prevMsg?.user_id) !== String(msg.user_id) const showHeader = !own && isNewGroup return (
{/* username + avatar for others */} {showHeader && (
{msg.user_avatar ? ( ) : ( {(msg.username || '?')[0].toUpperCase()} )} {msg.username}
)} {/* reply quote */} {repliedMsg && (
{repliedMsg.username}: {(repliedMsg.text || '').slice(0, 80)} {(repliedMsg.text || '').length > 80 ? '...' : ''}
)} {/* bubble with hover actions */}
setHoveredId(msg.id)} onMouseLeave={() => setHoveredId(null)} >
{msg.text}
{/* action buttons */}
{own && ( )}
{/* timestamp */} {formatRelativeTime(msg.created_at, t)}
) })}
)} {/* ── composer ── */}
{/* reply preview */} {replyTo && (
{replyTo.username}: {(replyTo.text || '').slice(0, 60)} {(replyTo.text || '').length > 60 ? '...' : ''} setReplyTo(null)} > ×
)}