From 068b90ed72b671e1c0ad9cf2fc51988e5d4cacc1 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 25 Mar 2026 22:59:39 +0100 Subject: [PATCH] =?UTF-8?q?v2.6.0=20=E2=80=94=20Collab=20overhaul,=20route?= =?UTF-8?q?=20travel=20times,=20chat=20&=20notes=20redesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Collab — Complete Redesign - iMessage-style live chat with blue bubbles, grouped messages, date separators - Emoji reactions via right-click (desktop) or double-tap (mobile) - Twemoji (Apple-style) emoji picker with categories - Link previews with OG image/title/description - Soft-delete messages with "deleted a message" placeholder - Message reactions with real-time WebSocket sync - Chat timestamps respect 12h/24h setting and timezone ## Collab Notes - Redesigned note cards with colored header bar (booking-card style) - 2-column grid layout (desktop), 1-column (mobile) - Category settings modal for managing categories with colors - File/image attachments on notes with mini-preview thumbnails - Website links with OG image preview on note cards - File preview portal (lightbox for images, inline viewer for PDF/TXT) - Note files appear in Files tab with "From Collab Notes" badge - Pin highlighting with tinted background - Author avatar chip in header bar with custom tooltip ## Collab Polls - Complete rewrite — clean Apple-style poll cards - Animated progress bars with vote percentages - Blue check circles for own votes, voter avatars - Create poll modal with multi-choice toggle - Active/closed poll sections - Custom tooltips on voter chips ## What's Next Widget - New widget showing upcoming trip activities - Time display with "until" separator - Participant chips per activity - Day grouping (Today, Tomorrow, dates) - Respects 12h/24h and locale settings ## Route Travel Times - Auto-calculated walking + driving times via OSRM (free, no API key) - Floating badge on each route segment between places - Walking person icon + car icon with times - Hides when zoomed out (< zoom 16) - Toggle in Settings > Display to enable/disable ## Other Improvements - Collab addon enabled by default for new installations - Coming Soon removed from Collab in admin settings - Tab state persisted across page reloads (sessionStorage) - Day sidebar expanded/collapsed state persisted - File preview with extension badges (PDF, TXT, etc.) in Files tab - Collab Notes filter tab in Files - Reservations section in Day Detail view - Dark mode fix for invite button text color - Chat scroll hidden (no visible scrollbar) - Mobile: tab icons removed for space, touch-friendly UI - Fixed 6 backend data structure bugs in Collab (polls, chat, notes) - Soft-delete for chat messages (persists in history) - Message reactions table (migration 28) - Note attachments via trip_files with note_id (migration 30) ## Database Migrations - Migration 27: budget_item_members table - Migration 28: collab_message_reactions table - Migration 29: soft-delete column on collab_messages - Migration 30: note_id on trip_files, website on collab_notes --- client/package.json | 2 +- client/src/api/client.js | 4 + client/src/components/Admin/AddonManager.jsx | 2 +- client/src/components/Collab/CollabChat.jsx | 1070 ++++++++------ client/src/components/Collab/CollabNotes.jsx | 1058 ++++++++------ client/src/components/Collab/CollabPanel.jsx | 99 +- client/src/components/Collab/CollabPolls.jsx | 1228 ++++------------- .../src/components/Collab/WhatsNextWidget.jsx | 189 +++ client/src/components/Files/FileManager.jsx | 22 +- client/src/components/Map/MapView.jsx | 65 +- client/src/components/Map/RouteCalculator.js | 5 + .../src/components/Planner/DayPlanSidebar.jsx | 24 +- client/src/i18n/translations/de.js | 37 + client/src/i18n/translations/en.js | 37 + client/src/index.css | 10 + client/src/pages/SettingsPage.jsx | 29 + client/src/pages/TripPlannerPage.jsx | 67 +- server/package.json | 2 +- server/src/db/database.js | 27 +- server/src/routes/collab.js | 401 ++++-- server/src/routes/files.js | 2 +- 21 files changed, 2367 insertions(+), 2013 deletions(-) create mode 100644 client/src/components/Collab/WhatsNextWidget.jsx diff --git a/client/package.json b/client/package.json index 28bf4d2..5420606 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "nomad-client", - "version": "2.5.7", + "version": "2.6.0", "private": true, "type": "module", "scripts": { diff --git a/client/src/api/client.js b/client/src/api/client.js index 6920b87..7b44777 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -204,6 +204,8 @@ export const collabApi = { createNote: (tripId, data) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data), updateNote: (tripId, id, data) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data), deleteNote: (tripId, id) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data), + uploadNoteFile: (tripId, noteId, formData) => apiClient.post(`/trips/${tripId}/collab/notes/${noteId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data), + deleteNoteFile: (tripId, noteId, fileId) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data), // Polls getPolls: (tripId) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data), createPoll: (tripId, data) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data), @@ -214,6 +216,8 @@ export const collabApi = { getMessages: (tripId, before) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data), sendMessage: (tripId, data) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data), deleteMessage: (tripId, id) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data), + reactMessage: (tripId, id, emoji) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji }).then(r => r.data), + linkPreview: (tripId, url) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data), } export const backupApi = { diff --git a/client/src/components/Admin/AddonManager.jsx b/client/src/components/Admin/AddonManager.jsx index 3f91448..82a82a4 100644 --- a/client/src/components/Admin/AddonManager.jsx +++ b/client/src/components/Admin/AddonManager.jsx @@ -118,7 +118,7 @@ export default function AddonManager() { } function AddonRow({ addon, onToggle, t }) { - const isComingSoon = addon.id === 'collab' + const isComingSoon = false return (
{/* Icon */} diff --git a/client/src/components/Collab/CollabChat.jsx b/client/src/components/Collab/CollabChat.jsx index 78b22e9..45c2efd 100644 --- a/client/src/components/Collab/CollabChat.jsx +++ b/client/src/components/Collab/CollabChat.jsx @@ -1,68 +1,328 @@ import React, { useState, useEffect, useRef, useCallback } from 'react' -import { Send, Trash2, Reply, ChevronUp, MessageCircle } from 'lucide-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 { 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}` +// ── 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 } - - const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'] - return `${monthNames[d.getMonth()]} ${d.getDate()}` + return codepoints.join('-') } -/* ─────────────────────────────────────── */ -/* Component */ -/* ─────────────────────────────────────── */ +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 ── */ +function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }) { + 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 = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉'] + +function ReactionMenu({ x, y, onReact, onClose }) { + 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 ── */ +function MessageText({ text }) { + 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 = {} + +function LinkPreview({ url, tripId, own, onLoad }) { + 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 ── */ +function ReactionBadge({ reaction, currentUserId, onReact }) { + 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 ── */ export default function CollabChat({ tripId, currentUser }) { const { t } = useTranslation() + const is12h = useSettingsStore(s => s.settings.time_format) === '12h' - const [messages, setMessages] = useState([]) - const [loading, setLoading] = useState(true) - const [hasMore, setHasMore] = useState(false) + 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 [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 scrollRef = useRef(null) + const containerRef = useRef(null) + const messagesRef = useRef(messages) + messagesRef.current = messages + const scrollRef = useRef(null) const textareaRef = useRef(null) - const isAtBottom = useRef(true) + const emojiBtnRef = 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 }) - }) + requestAnimationFrame(() => el.scrollTo({ top: el.scrollHeight, behavior })) }, []) const checkAtBottom = useCallback(() => { @@ -75,66 +335,50 @@ export default function CollabChat({ tripId, currentUser }) { useEffect(() => { let cancelled = false setLoading(true) - collabApi.getMessages(tripId).then(data => { if (cancelled) return - const msgs = Array.isArray(data) ? data : data.messages || [] + 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) - }) - + }).catch(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true } }, [tripId, scrollToBottom]) - /* ── load more (older messages) ── */ + /* ── 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 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 { + 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 - }) + requestAnimationFrame(() => { if (el) el.scrollTop = el.scrollHeight - prevHeight }) } - } catch { - // silently ignore - } finally { - setLoadingMore(false) - } + } 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 => { - if (prev.some(m => m.id === event.message.id)) return prev - return [...prev, event.message] - }) - if (isAtBottom.current) { - setTimeout(() => scrollToBottom('smooth'), 30) - } + 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.filter(m => m.id !== event.messageId)) + 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]) @@ -145,7 +389,9 @@ export default function CollabChat({ tripId, currentUser }) { const ta = textareaRef.current if (ta) { ta.style.height = 'auto' - ta.style.height = Math.min(ta.scrollHeight, 3 * 20 + 18) + 'px' + const h = Math.min(ta.scrollHeight, 100) + ta.style.height = h + 'px' + ta.style.overflowY = ta.scrollHeight > 100 ? 'auto' : 'hidden' } }, []) @@ -153,164 +399,92 @@ export default function CollabChat({ tripId, currentUser }) { 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] - }) + setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...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) - } + 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]) - /* ── keyboard ── */ const handleKeyDown = useCallback((e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - handleSend() - } + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } }, [handleSend]) - /* ── delete ── */ const handleDelete = useCallback(async (msgId) => { - try { - await collabApi.deleteMessage(tripId, msgId) - } catch { - // ignore - } + const msg = messages.find(m => m.id === msgId) + requestAnimationFrame(() => { + setDeletingIds(prev => new Set(prev).add(msgId)) + }) + 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) }, [tripId]) - /* ── find a replied-to message ── */ - const findMessage = useCallback((id) => messages.find(m => m.id === id), [messages]) + 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() + }, []) - /* ── helpers ── */ const isOwn = (msg) => String(msg.user_id) === String(currentUser.id) - const font = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" + // 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()) + } - /* ───────── render: loading ───────── */ + /* ── Loading ── */ if (loading) { return ( -
-
-
- -
+
+
+
) } - /* ───────── render: main ───────── */ + /* ── Main ── */ return ( -
- {/* ── messages area ── */} +
+ {/* Messages */} {messages.length === 0 ? ( -
- - - {t('collab.chat.empty')} - - - {t('collab.chat.emptyDesc') || ''} - +
+ + {t('collab.chat.empty')} + {t('collab.chat.emptyDesc') || ''}
) : ( -
- {/* load more */} +
{hasMore && ( -
- @@ -319,297 +493,283 @@ export default function CollabChat({ tripId, currentUser }) { {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 nextMsg = messages[idx + 1] const isNewGroup = idx === 0 || String(prevMsg?.user_id) !== String(msg.user_id) - const showHeader = !own && isNewGroup + 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 ( + + {showDate && ( +
+ + {formatDateSeparator(msg.created_at, t)} + +
+ )} +
+ + {msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)} + +
+
+ ) + } + + // Bubble border radius — iMessage style tails + const br = own + ? `18px 18px ${isLastInGroup ? '4px' : '18px'} 18px` + : `18px 18px 18px ${isLastInGroup ? '4px' : '18px'}` return ( -
- {/* username + avatar for others */} - {showHeader && ( -
- {msg.user_avatar ? ( - - ) : ( - - {(msg.username || '?')[0].toUpperCase()} - - )} + + {/* Date separator */} + {showDate && ( +
- {msg.username} + {formatDateSeparator(msg.created_at, t)}
)} - {/* reply quote */} - {repliedMsg && ( -
- {repliedMsg.username}: - {(repliedMsg.text || '').slice(0, 80)} - {(repliedMsg.text || '').length > 80 ? '...' : ''} -
- )} +
+ {/* Avatar slot for others */} + {!own && ( +
+ {showAvatar && ( + msg.user_avatar ? ( + + ) : ( +
+ {(msg.username || '?')[0].toUpperCase()} +
+ ) + )} +
+ )} - {/* bubble with hover actions */} -
setHoveredId(msg.id)} - onMouseLeave={() => setHoveredId(null)} - > -
- {msg.text} -
+
+ {/* Username for others at group start */} + {!own && isNewGroup && ( + + {msg.username} + + )} - {/* action buttons */} -
- - {own && ( -
+ ) : ( +
+ {/* Inline reply quote */} + {hasReply && ( +
+
+ {msg.reply_username || ''} +
+
+ {(msg.reply_text || '').slice(0, 80)} +
+
+ )} + {hasReply ? ( +
+ ) : } + {(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => ( + { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} /> + ))} +
+ )} + + {/* Hover actions */} +
+ + onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)' }} + onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)' }} + > + + + {own && ( + + )} +
+
+ + {/* Reactions — iMessage style floating badge */} + {msg.reactions?.length > 0 && ( +
+
+ {msg.reactions.map(r => { + const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id)) + return ( + handleReact(msg.id, r.emoji)} /> + ) + })} +
+
+ )} + + {/* Timestamp — only on last message of group */} + {isLastInGroup && ( + + {formatTime(msg.created_at, is12h)} + )}
- - {/* timestamp */} - - {formatRelativeTime(msg.created_at, t)} - -
+ ) })}
)} - {/* ── composer ── */} -
- {/* reply preview */} + {/* Composer */} +
+ {/* Reply preview */} {replyTo && (
- - + + {replyTo.username}: {(replyTo.text || '').slice(0, 60)} - {(replyTo.text || '').length > 60 ? '...' : ''} - - setReplyTo(null)} - > - × +
)} -
+
+ {/* Emoji button */} + +