import React, { useState, useEffect, useRef, useCallback } from 'react' import ReactDOM from 'react-dom' import { ArrowUp, Trash2, Reply, ChevronUp, MessageCircle, Smile, X } from 'lucide-react' import { collabApi } from '../../api/client' import { useSettingsStore } from '../../store/settingsStore' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' import { addListener, removeListener } from '../../api/websocket' import { useTranslation } from '../../i18n' import type { User } from '../../types' interface ChatReaction { emoji: string count: number users: { id: number; username: string }[] } interface ChatMessage { id: number trip_id: number user_id: number text: string reply_to_id: number | null reactions: ChatReaction[] created_at: string user?: { username: string; avatar_url: string | null } reply_to?: ChatMessage | null } // โ”€โ”€ Twemoji helper (Apple-style emojis via CDN) โ”€โ”€ function emojiToCodepoint(emoji) { const codepoints = [] for (const c of emoji) { const cp = c.codePointAt(0) if (cp !== 0xfe0f) codepoints.push(cp.toString(16)) // skip variation selector } return codepoints.join('-') } function TwemojiImg({ emoji, size = 20, style = {} }) { const cp = emojiToCodepoint(emoji) const [failed, setFailed] = useState(false) if (failed) { return {emoji} } return ( {emoji} setFailed(true)} /> ) } const EMOJI_CATEGORIES = { 'Smileys': ['๐Ÿ˜€','๐Ÿ˜‚','๐Ÿฅน','๐Ÿ˜','๐Ÿคฉ','๐Ÿ˜Ž','๐Ÿฅณ','๐Ÿ˜ญ','๐Ÿค”','๐Ÿ‘€','๐Ÿ™ˆ','๐Ÿซ ','๐Ÿ˜ด','๐Ÿคฏ','๐Ÿฅบ','๐Ÿ˜ค','๐Ÿ’€','๐Ÿ‘ป','๐Ÿซก','๐Ÿค'], 'Reactions': ['โค๏ธ','๐Ÿ”ฅ','๐Ÿ‘','๐Ÿ‘Ž','๐Ÿ‘','๐ŸŽ‰','๐Ÿ’ฏ','โœจ','โญ','๐Ÿ’ช','๐Ÿ™','๐Ÿ˜ฑ','๐Ÿ˜‚','๐Ÿ’–','๐Ÿ’•','๐Ÿคž','โœ…','โŒ','โšก','๐Ÿ†'], 'Travel': ['โœˆ๏ธ','๐Ÿ–๏ธ','๐Ÿ—บ๏ธ','๐Ÿงณ','๐Ÿ”๏ธ','๐ŸŒ…','๐ŸŒด','๐Ÿš—','๐Ÿš‚','๐Ÿ›ณ๏ธ','๐Ÿจ','๐Ÿฝ๏ธ','๐Ÿ•','๐Ÿน','๐Ÿ“ธ','๐ŸŽ’','โ›ฑ๏ธ','๐ŸŒ','๐Ÿ—ผ','๐ŸŽŒ'], } // SQLite stores UTC without 'Z' suffix โ€” append it so JS parses as UTC function parseUTC(s) { return new Date(s && !s.endsWith('Z') ? s + 'Z' : s) } function formatTime(isoString, is12h) { const d = parseUTC(isoString) const h = d.getHours() const mm = String(d.getMinutes()).padStart(2, '0') if (is12h) { const period = h >= 12 ? 'PM' : 'AM' const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h return `${h12}:${mm} ${period}` } return `${String(h).padStart(2, '0')}:${mm}` } function formatDateSeparator(isoString, t) { const d = parseUTC(isoString) const now = new Date() const yesterday = new Date(); yesterday.setDate(now.getDate() - 1) if (d.toDateString() === now.toDateString()) return t('collab.chat.today') || 'Today' if (d.toDateString() === yesterday.toDateString()) return t('collab.chat.yesterday') || 'Yesterday' return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) } function shouldShowDateSeparator(msg, prevMsg) { if (!prevMsg) return true const d1 = parseUTC(msg.created_at).toDateString() const d2 = parseUTC(prevMsg.created_at).toDateString() return d1 !== d2 } /* โ”€โ”€ Emoji Picker โ”€โ”€ */ interface EmojiPickerProps { onSelect: (emoji: string) => void onClose: () => void anchorRef: React.RefObject containerRef: React.RefObject } function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: EmojiPickerProps) { const [cat, setCat] = useState(Object.keys(EMOJI_CATEGORIES)[0]) const ref = useRef(null) const getPos = () => { const container = containerRef?.current const anchor = anchorRef?.current if (container && anchor) { const cRect = container.getBoundingClientRect() const aRect = anchor.getBoundingClientRect() return { bottom: window.innerHeight - aRect.top + 16, left: cRect.left + cRect.width / 2 - 140 } } return { bottom: 80, left: 0 } } const pos = getPos() useEffect(() => { const close = (e) => { if (ref.current && ref.current.contains(e.target)) return if (anchorRef?.current && anchorRef.current.contains(e.target)) return onClose() } document.addEventListener('mousedown', close) return () => document.removeEventListener('mousedown', close) }, [onClose, anchorRef]) return ReactDOM.createPortal(
{/* Category tabs */}
{Object.keys(EMOJI_CATEGORIES).map(c => ( ))}
{/* Emoji grid */}
{EMOJI_CATEGORIES[cat].map((emoji, i) => ( ))}
, document.body ) } /* โ”€โ”€ Reaction Quick Menu (right-click) โ”€โ”€ */ const QUICK_REACTIONS = ['โค๏ธ', '๐Ÿ˜‚', '๐Ÿ‘', '๐Ÿ˜ฎ', '๐Ÿ˜ข', '๐Ÿ”ฅ', '๐Ÿ‘', '๐ŸŽ‰'] interface ReactionMenuProps { x: number y: number onReact: (emoji: string) => void onClose: () => void } function ReactionMenu({ x, y, onReact, onClose }: ReactionMenuProps) { const ref = useRef(null) useEffect(() => { const close = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose() } document.addEventListener('mousedown', close) return () => document.removeEventListener('mousedown', close) }, [onClose]) // Clamp to viewport const menuWidth = 156 const clampedLeft = Math.max(menuWidth / 2 + 8, Math.min(x, window.innerWidth - menuWidth / 2 - 8)) return (
{QUICK_REACTIONS.map(emoji => ( ))}
) } /* โ”€โ”€ Message Text with clickable URLs โ”€โ”€ */ interface MessageTextProps { text: string } function MessageText({ text }: MessageTextProps) { const parts = text.split(URL_REGEX) const urls = text.match(URL_REGEX) || [] const result = [] parts.forEach((part, i) => { if (part) result.push(part) if (urls[i]) result.push( {urls[i]} ) }) return <>{result} } /* โ”€โ”€ Link Preview โ”€โ”€ */ const URL_REGEX = /https?:\/\/[^\s<>"']+/g const previewCache = {} interface LinkPreviewProps { url: string tripId: number own: boolean onLoad: (() => void) | undefined } function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) { const [data, setData] = useState(previewCache[url] || null) const [loading, setLoading] = useState(!previewCache[url]) useEffect(() => { if (previewCache[url]) return collabApi.linkPreview(tripId, url).then(d => { previewCache[url] = d setData(d) setLoading(false) if (d?.title || d?.description || d?.image) onLoad?.() }).catch(() => setLoading(false)) }, [url, tripId]) if (loading || !data || (!data.title && !data.description && !data.image)) return null const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return '' } })() return ( e.currentTarget.style.opacity = '0.85'} onMouseLeave={e => e.currentTarget.style.opacity = '1'} > {data.image && ( e.target.style.display = 'none'} /> )}
{domain && (
{data.site_name || domain}
)} {data.title && (
{data.title}
)} {data.description && (
{data.description}
)}
) } /* โ”€โ”€ Reaction Badge with NOMAD tooltip โ”€โ”€ */ interface ReactionBadgeProps { reaction: ChatReaction currentUserId: number onReact: () => void } function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadgeProps) { const [hover, setHover] = useState(false) const [pos, setPos] = useState({ top: 0, left: 0 }) const ref = useRef(null) const names = reaction.users.map(u => u.username).join(', ') return ( <> {hover && names && ReactDOM.createPortal(
{names}
, document.body )} ) } /* โ”€โ”€ Main Component โ”€โ”€ */ interface CollabChatProps { tripId: number currentUser: User } export default function CollabChat({ tripId, currentUser }: CollabChatProps) { const S = useCollabChat(tripId, currentUser) const { t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = S if (loading) { return (
) } return (
{/* Composer */}
{/* Reply preview */} {replyTo && (
{replyTo.username}: {(replyTo.text || '').slice(0, 60)}
)}
{/* Emoji button */} {canEdit && ( )}