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 (
+

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 && (
-
-