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 (