Files
TREK/client/src/components/Collab/CollabChat.tsx
T
Maurice fc7d8b5d12 Migrate TREK 3 to NestJS + React 19 with a shared Zod contract layer
Brownfield strangler migration of the backend onto NestJS modules
(auth, trips, days, places, assignments, packing, todo, budget,
reservations, collab, files, photos, journey, share, settings, backup,
oidc, oauth, admin, atlas, vacay, weather, airports, maps, categories,
tags, notifications, system-notices) served through a per-prefix
dispatcher, keeping the existing SQLite/better-sqlite3 DB and JWT
httpOnly cookie auth, with behavioural parity for every route.

Client: React 19 upgrade, "page = wiring container + data hook"
pattern across all pages, per-domain Zustand stores bound to
@trek/shared contracts, and decomposition of the large components
(DayPlanSidebar, PackingListPanel, CollabNotes, FileManager,
MemoriesPanel, PlacesSidebar, CollabChat, SystemNoticeModal,
BudgetPanel, PlaceFormModal, ...) into focused render units backed by
in-file hooks.

Apply the shared global request pipeline (helmet/CSP, CORS, HSTS,
forced HTTPS, the global MFA policy and request logging) to the NestJS
instance as well, so a migrated route is protected identically to the
legacy fallback rather than bypassing it.
2026-05-30 02:39:26 +02:00

860 lines
39 KiB
TypeScript

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 <span style={{ fontSize: size, lineHeight: 1, display: 'inline-block', verticalAlign: 'middle', ...style }}>{emoji}</span>
}
return (
<img
src={`https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/${cp}.png`}
alt={emoji}
draggable={false}
style={{ width: size, height: size, display: 'inline-block', verticalAlign: 'middle', ...style }}
onError={() => 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<HTMLElement | null>
containerRef: React.RefObject<HTMLElement | null>
}
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(
<div ref={ref} style={{
position: 'fixed', bottom: pos.bottom, left: pos.left, zIndex: 10000,
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
boxShadow: '0 8px 32px rgba(0,0,0,0.18)', width: 280, overflow: 'hidden',
}}>
{/* Category tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-faint)', padding: '6px 8px', gap: 2 }}>
{Object.keys(EMOJI_CATEGORIES).map(c => (
<button key={c} onClick={() => setCat(c)} style={{
flex: 1, padding: '4px 0', borderRadius: 6, border: 'none', cursor: 'pointer',
background: cat === c ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-primary)', fontSize: 10, fontWeight: 600, fontFamily: 'inherit',
}}>
{c}
</button>
))}
</div>
{/* Emoji grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(10, 1fr)', gap: 2, padding: 8 }}>
{EMOJI_CATEGORIES[cat].map((emoji, i) => (
<button key={i} onClick={() => onSelect(emoji)} style={{
width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'none', border: 'none', cursor: 'pointer', borderRadius: 6,
padding: 2, transition: 'transform 0.1s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.transform = 'scale(1.2)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'none'; e.currentTarget.style.transform = 'scale(1)' }}
>
<TwemojiImg emoji={emoji} size={20} />
</button>
))}
</div>
</div>,
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 (
<div ref={ref} style={{
position: 'fixed', top: y - 80, left: clampedLeft, transform: 'translateX(-50%)', zIndex: 10000,
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: '6px 8px',
display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 2, width: menuWidth,
}}>
{QUICK_REACTIONS.map(emoji => (
<button key={emoji} onClick={() => onReact(emoji)} style={{
width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'none', border: 'none', cursor: 'pointer', borderRadius: '50%',
padding: 3, transition: 'transform 0.1s, background 0.1s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'none' }}
>
<TwemojiImg emoji={emoji} size={18} />
</button>
))}
</div>
)
}
/* ── 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(
<a key={i} href={urls[i]} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline', textUnderlineOffset: 2, opacity: 0.85 }}>
{urls[i]}
</a>
)
})
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 (
<a href={url} target="_blank" rel="noopener noreferrer" style={{
display: 'block', textDecoration: 'none', marginTop: 6, borderRadius: 12, overflow: 'hidden',
border: own ? '1px solid rgba(255,255,255,0.15)' : '1px solid var(--border-faint)',
background: own ? 'rgba(255,255,255,0.1)' : 'var(--bg-secondary)',
maxWidth: 280, transition: 'opacity 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
{data.image && (
<img src={data.image} alt="" style={{ width: '100%', height: 140, objectFit: 'cover', display: 'block' }}
onError={e => e.target.style.display = 'none'} />
)}
<div style={{ padding: '8px 10px' }}>
{domain && (
<div style={{ fontSize: 10, fontWeight: 600, color: own ? 'rgba(255,255,255,0.5)' : 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, marginBottom: 2 }}>
{data.site_name || domain}
</div>
)}
{data.title && (
<div style={{ fontSize: 12, fontWeight: 600, color: own ? '#fff' : 'var(--text-primary)', lineHeight: 1.3, marginBottom: 2, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{data.title}
</div>
)}
{data.description && (
<div style={{ fontSize: 11, color: own ? 'rgba(255,255,255,0.7)' : 'var(--text-muted)', lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{data.description}
</div>
)}
</div>
</a>
)
}
/* ── 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 (
<>
<button ref={ref} onClick={onReact}
onMouseEnter={() => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect()
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
}
setHover(true)
}}
onMouseLeave={() => setHover(false)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '1px 3px',
borderRadius: 99, border: 'none', cursor: 'pointer', fontFamily: 'inherit',
background: 'transparent', transition: 'transform 0.1s',
}}
>
<TwemojiImg emoji={reaction.emoji} size={16} />
{reaction.count > 1 && <span style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', minWidth: 8 }}>{reaction.count}</span>}
</button>
{hover && names && ReactDOM.createPortal(
<div style={{
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
}}>
{names}
</div>,
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 (
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<div style={{ width: 24, height: 24, border: '2px solid var(--border-faint)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin .7s linear infinite' }} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
}
return (
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, height: '100%' }}>
<ChatMessages {...S} />
{/* Composer */}
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }} className="pb-3">
{/* Reply preview */}
{replyTo && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8,
padding: '6px 10px', borderRadius: 10, background: 'var(--bg-secondary)',
borderLeft: '3px solid #007AFF', fontSize: 12, color: 'var(--text-muted)',
}}>
<Reply size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
<strong>{replyTo.username}</strong>: {(replyTo.text || '').slice(0, 60)}
</span>
<button onClick={() => setReplyTo(null)} style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)',
display: 'flex', flexShrink: 0,
}}>
<X size={14} />
</button>
</div>
)}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
{/* Emoji button */}
{canEdit && (
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
}}>
<Smile size={20} />
</button>
)}
<textarea
ref={textareaRef}
rows={1}
disabled={!canEdit}
style={{
flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20,
padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit',
background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none',
maxHeight: 100, overflowY: 'hidden',
opacity: canEdit ? 1 : 0.5,
}}
placeholder={t('collab.chat.placeholder')}
value={text}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
/>
{/* Send */}
{canEdit && (
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
transition: 'background 0.15s',
}}>
<ArrowUp size={18} strokeWidth={2.5} />
</button>
)}
</div>
</div>
{/* Emoji picker */}
{showEmoji && <EmojiPicker onSelect={handleEmojiSelect} onClose={() => setShowEmoji(false)} anchorRef={emojiBtnRef} containerRef={containerRef} />}
{/* Reaction quick menu (right-click) */}
{reactMenu && ReactDOM.createPortal(
<ReactionMenu x={reactMenu.x} y={reactMenu.y} onReact={(emoji) => handleReact(reactMenu.msgId, emoji)} onClose={() => setReactMenu(null)} />,
document.body
)}
</div>
)
}
function useCollabChat(tripId: any, currentUser: any) {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('collab_edit', trip)
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 [showEmoji, setShowEmoji] = useState(false)
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
const [deletingIds, setDeletingIds] = useState(new Set())
const deleteTimersRef = useRef<ReturnType<typeof setTimeout>[]>([])
useEffect(() => {
return () => { deleteTimersRef.current.forEach(clearTimeout) }
}, [])
const containerRef = useRef(null)
const messagesRef = useRef(messages)
messagesRef.current = messages
const scrollRef = useRef(null)
const textareaRef = useRef(null)
const emojiBtnRef = useRef(null)
const isAtBottom = useRef(true)
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 || []).map(m => m.deleted ? { ...m, _deleted: true } : m)
setMessages(msgs)
setHasMore(msgs.length >= 100)
setLoading(false)
setTimeout(() => scrollToBottom(), 30)
}).catch(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [tripId, scrollToBottom])
/* ── load more ── */
const handleLoadMore = useCallback(async () => {
if (loadingMore || messages.length === 0) return
setLoadingMore(true)
const el = scrollRef.current
const prevHeight = el ? el.scrollHeight : 0
try {
const data = await collabApi.getMessages(tripId, messages[0]?.id)
const older = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m)
if (older.length === 0) { setHasMore(false) }
else {
setMessages(prev => [...older, ...prev])
setHasMore(older.length >= 100)
requestAnimationFrame(() => { if (el) el.scrollTop = el.scrollHeight - prevHeight })
}
} catch {} finally { setLoadingMore(false) }
}, [tripId, loadingMore, messages])
/* ── websocket ── */
useEffect(() => {
const handler = (event) => {
if (event.type === 'collab:message:created' && String(event.tripId) === String(tripId)) {
setMessages(prev => prev.some(m => m.id === event.message.id) ? prev : [...prev, event.message])
if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 30)
}
if (event.type === 'collab:message:deleted' && String(event.tripId) === String(tripId)) {
setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, _deleted: true } : m))
if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50)
}
if (event.type === 'collab:message:reacted' && String(event.tripId) === String(tripId)) {
setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, reactions: event.reactions } : m))
}
}
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'
const h = Math.min(ta.scrollHeight, 100)
ta.style.height = h + 'px'
ta.style.overflowY = ta.scrollHeight > 100 ? 'auto' : 'hidden'
}
}, [])
/* ── 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 => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message])
}
setText(''); setReplyTo(null); setShowEmoji(false)
if (textareaRef.current) textareaRef.current.style.height = 'auto'
isAtBottom.current = true
setTimeout(() => scrollToBottom('smooth'), 50)
} catch {} finally { setSending(false) }
}, [text, sending, replyTo, tripId, scrollToBottom])
const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
}, [handleSend])
const handleDelete = useCallback(async (msgId) => {
const msg = messages.find(m => m.id === msgId)
requestAnimationFrame(() => {
setDeletingIds(prev => new Set(prev).add(msgId))
})
const t = setTimeout(async () => {
try {
await collabApi.deleteMessage(tripId, msgId)
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m))
} catch {}
setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s })
}, 400)
deleteTimersRef.current.push(t)
}, [tripId])
const handleReact = useCallback(async (msgId, emoji) => {
setReactMenu(null)
try {
const data = await collabApi.reactMessage(tripId, msgId, emoji)
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, reactions: data.reactions } : m))
} catch {}
}, [tripId])
const handleEmojiSelect = useCallback((emoji) => {
setText(prev => prev + emoji)
textareaRef.current?.focus()
}, [])
const isOwn = (msg) => String(msg.user_id) === String(currentUser.id)
// Check if message is only emoji (1-3 emojis, no other text)
const isEmojiOnly = (text) => {
const emojiRegex = /^(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}[\uFE0F]?(?:\u200D\p{Extended_Pictographic}[\uFE0F]?)*){1,3}$/u
return emojiRegex.test(text.trim())
}
return { currentUser, tripId, 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 }
}
function ChatMessages(props: any) {
const { currentUser, tripId, 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 } = props
return (
<>
{/* Messages */}
{messages.length === 0 ? (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--text-faint)', padding: 32 }}>
<MessageCircle size={40} strokeWidth={1.2} style={{ opacity: 0.4 }} />
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('collab.chat.empty')}</span>
<span style={{ fontSize: 12, opacity: 0.6 }}>{t('collab.chat.emptyDesc') || ''}</span>
</div>
) : (
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 14px 4px', WebkitOverflowScrolling: 'touch',
display: 'flex', flexDirection: 'column', gap: 1,
}}>
{hasMore && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0 10px' }}>
<button onClick={handleLoadMore} disabled={loadingMore} style={{
display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600,
color: 'var(--text-muted)', background: 'var(--bg-secondary)', border: '1px solid var(--border-faint)',
borderRadius: 99, padding: '5px 14px', cursor: 'pointer', fontFamily: 'inherit',
}}>
<ChevronUp size={13} />
{loadingMore ? '...' : t('collab.chat.loadMore')}
</button>
</div>
)}
{messages.map((msg, idx) => {
const own = isOwn(msg)
const prevMsg = messages[idx - 1]
const nextMsg = messages[idx + 1]
const isNewGroup = idx === 0 || String(prevMsg?.user_id) !== String(msg.user_id)
const isLastInGroup = !nextMsg || String(nextMsg?.user_id) !== String(msg.user_id)
const showDate = shouldShowDateSeparator(msg, prevMsg)
const showAvatar = !own && isLastInGroup
const bigEmoji = isEmojiOnly(msg.text)
const hasReply = msg.reply_text || msg.reply_to
// Deleted message placeholder
if (msg._deleted) {
return (
<React.Fragment key={msg.id}>
{showDate && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99, letterSpacing: 0.3, textTransform: 'uppercase' }}>
{formatDateSeparator(msg.created_at, t)}
</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}>
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontStyle: 'italic' }}>
{msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)}
</span>
</div>
</React.Fragment>
)
}
// Bubble border radius — iMessage style tails
const br = own
? `18px 18px ${isLastInGroup ? '4px' : '18px'} 18px`
: `18px 18px 18px ${isLastInGroup ? '4px' : '18px'}`
return (
<React.Fragment key={msg.id}>
{/* Date separator */}
{showDate && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
<span style={{
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99,
letterSpacing: 0.3, textTransform: 'uppercase',
}}>
{formatDateSeparator(msg.created_at, t)}
</span>
</div>
)}
<div style={{
display: 'flex', alignItems: own ? 'flex-end' : 'flex-start',
flexDirection: own ? 'row-reverse' : 'row',
gap: 6, marginTop: isNewGroup ? 10 : 1,
paddingLeft: own ? 40 : 0, paddingRight: own ? 0 : 40,
transition: 'transform 0.3s ease, opacity 0.3s ease, max-height 0.3s ease',
...(deletingIds.has(msg.id) ? { transform: 'scale(0.3)', opacity: 0, maxHeight: 0, marginTop: 0, overflow: 'hidden' } : {}),
}}>
{/* Avatar slot for others */}
{!own && (
<div style={{ width: 28, flexShrink: 0, alignSelf: 'flex-end' }}>
{showAvatar && (
msg.user_avatar ? (
<img src={msg.user_avatar} alt="" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} />
) : (
<div style={{
width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 11, fontWeight: 700, color: 'var(--text-muted)',
}}>
{(msg.username || '?')[0].toUpperCase()}
</div>
)
)}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: own ? 'flex-end' : 'flex-start', maxWidth: '78%', minWidth: 0 }}>
{/* Username for others at group start */}
{!own && isNewGroup && (
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingLeft: 4 }}>
{msg.username}
</span>
)}
{/* Bubble */}
<div
style={{ position: 'relative' }}
onMouseEnter={() => setHoveredId(msg.id)}
onMouseLeave={() => setHoveredId(null)}
onContextMenu={e => { e.preventDefault(); if (canEdit) setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
onTouchEnd={e => {
const now = Date.now()
const lastTap = e.currentTarget.dataset.lastTap || 0
if (now - lastTap < 300 && canEdit) {
e.preventDefault()
const touch = e.changedTouches?.[0]
if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY })
}
e.currentTarget.dataset.lastTap = now
}}
>
{bigEmoji ? (
<div style={{ fontSize: 40, lineHeight: 1.2, padding: '2px 0' }}>
{msg.text}
</div>
) : (
<div style={{
background: own ? '#007AFF' : 'var(--bg-secondary)',
color: own ? '#fff' : 'var(--text-primary)',
borderRadius: br, padding: hasReply ? '4px 4px 8px 4px' : '8px 14px',
fontSize: 14, lineHeight: 1.4, wordBreak: 'break-word', whiteSpace: 'pre-wrap',
}}>
{/* Inline reply quote */}
{hasReply && (
<div style={{
padding: '5px 10px', marginBottom: 4, borderRadius: 12,
background: own ? 'rgba(255,255,255,0.15)' : 'var(--bg-tertiary)',
fontSize: 12, lineHeight: 1.3,
}}>
<div style={{ fontWeight: 600, fontSize: 11, opacity: 0.7, marginBottom: 1 }}>
{msg.reply_username || ''}
</div>
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{(msg.reply_text || '').slice(0, 80)}
</div>
</div>
)}
{hasReply ? (
<div style={{ padding: '0 10px 4px' }}><MessageText text={msg.text} /></div>
) : <MessageText text={msg.text} />}
{(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => (
<LinkPreview key={url} url={url} tripId={tripId} own={own} onLoad={() => { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} />
))}
</div>
)}
{/* Hover actions */}
<div style={{
position: 'absolute', top: -14,
display: 'flex', gap: 2,
opacity: hoveredId === msg.id ? 1 : 0,
pointerEvents: hoveredId === msg.id ? 'auto' : 'none',
transition: 'opacity .1s',
...(own ? { left: -6 } : { right: -6 }),
}}>
<button onClick={() => setReplyTo(msg)} title={t('collab.chat.reply')} style={{
width: 24, height: 24, borderRadius: '50%', border: 'none',
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)' }}
>
<Reply size={11} />
</button>
{own && canEdit && (
<button onClick={() => handleDelete(msg.id)} title={t('common.delete')} style={{
width: 24, height: 24, borderRadius: '50%', border: 'none',
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s, background 0.15s, color 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = '#ef4444'; e.currentTarget.style.color = '#fff' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
>
<Trash2 size={11} />
</button>
)}
</div>
</div>
{/* Reactions — iMessage style floating badge */}
{msg.reactions?.length > 0 && (
<div style={{
display: 'flex', gap: 3, marginTop: -6, marginBottom: 4,
justifyContent: own ? 'flex-end' : 'flex-start',
paddingLeft: own ? 0 : 8, paddingRight: own ? 8 : 0,
position: 'relative', zIndex: 1,
}}>
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '3px 6px',
borderRadius: 99, background: 'var(--bg-card)',
boxShadow: '0 1px 6px rgba(0,0,0,0.12)', border: '1px solid var(--border-faint)',
}}>
{msg.reactions.map(r => {
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
return (
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
)
})}
</div>
</div>
)}
{/* Timestamp — only on last message of group */}
{isLastInGroup && (
<span style={{ fontSize: 9, color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}>
{formatTime(msg.created_at, is12h)}
</span>
)}
</div>
</div>
</React.Fragment>
)
})}
</div>
)}
</>
)
}