+
+ {initials}
-
-
{preview}
- {unread > 0 && (
-
- {unread > 99 ? '99+' : unread}
-
- )}
+
+
+
+
+
+
+ {label}
+
+ {isPinned ? Pinned : null}
+ {isArchived ? Archived : null}
+
+
{typeLabel}
+
+
+
+ {conv.last_message_at ? {relativeTime(conv.last_message_at)} : null}
+ {unread > 0 ? (
+
+ {unread > 99 ? '99+' : unread}
+
+ ) : null}
+
+
+
+
+ {senderLabel ?
{senderLabel}
: null}
+
{preview}
+
@@ -101,22 +99,21 @@ function ConversationRow({ conv, isActive, currentUserId, onClick }) {
)
}
-// ── Helpers ──────────────────────────────────────────────────────────────────
-
function convLabel(conv, currentUserId) {
if (conv.type === 'group') return conv.title ?? 'Group'
- const other = conv.all_participants?.find(p => p.user_id !== currentUserId)
+ const other = conv.all_participants?.find((participant) => participant.user_id !== currentUserId)
return other?.user?.username ?? 'Direct message'
}
function truncate(str, max) {
if (!str) return ''
- return str.length > max ? str.slice(0, max) + '…' : str
+ return str.length > max ? `${str.slice(0, max)}…` : str
}
function relativeTime(iso) {
+ if (!iso) return 'No activity'
const diff = (Date.now() - new Date(iso).getTime()) / 1000
- if (diff < 60) return 'now'
+ if (diff < 60) return 'Now'
if (diff < 3600) return `${Math.floor(diff / 60)}m`
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
return `${Math.floor(diff / 86400)}d`
diff --git a/resources/js/components/messaging/ConversationThread.jsx b/resources/js/components/messaging/ConversationThread.jsx
index 5e84e160..e83b8443 100644
--- a/resources/js/components/messaging/ConversationThread.jsx
+++ b/resources/js/components/messaging/ConversationThread.jsx
@@ -1,13 +1,10 @@
-import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import MessageBubble from './MessageBubble'
-/**
- * Right panel: scrollable thread of messages with send form.
- */
export default function ConversationThread({
conversationId,
conversation,
- realtimeEnabled = false,
+ realtimeEnabled,
currentUserId,
currentUsername,
apiFetch,
@@ -15,698 +12,765 @@ export default function ConversationThread({
onMarkRead,
onConversationUpdated,
}) {
- const [messages, setMessages] = useState([])
- const [loading, setLoading] = useState(true)
- const [sending, setSending] = useState(false)
- const [body, setBody] = useState('')
- const [nextCursor, setNextCursor] = useState(null)
+ const [messages, setMessages] = useState([])
+ const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
- const [error, setError] = useState(null)
- const [attachments, setAttachments] = useState([])
- const [uploadProgress, setUploadProgress] = useState(null)
+ const [nextCursor, setNextCursor] = useState(null)
+ const [body, setBody] = useState('')
+ const [files, setFiles] = useState([])
+ const [sending, setSending] = useState(false)
+ const [error, setError] = useState(null)
const [typingUsers, setTypingUsers] = useState([])
const [threadSearch, setThreadSearch] = useState('')
- const [threadSearchResults, setThreadSearchResults] = useState([])
- const [lightboxImage, setLightboxImage] = useState(null)
- const fileInputRef = useRef(null)
- const bottomRef = useRef(null)
- const threadRef = useRef(null)
- const pollRef = useRef(null)
- const typingPollRef = useRef(null)
- const typingStopTimerRef = useRef(null)
- const latestIdRef = useRef(null)
- const shouldAutoScrollRef = useRef(true)
- const draftKey = `nova_draft_${conversationId}`
- const previewAttachments = useMemo(() => {
- return attachments.map(file => ({
- file,
- previewUrl: isImageLike(file) ? URL.createObjectURL(file) : null,
- }))
- }, [attachments])
+ const [busyAction, setBusyAction] = useState(null)
+ const [lightbox, setLightbox] = useState(null)
+ const [draftTitle, setDraftTitle] = useState(conversation?.title ?? '')
+ const listRef = useRef(null)
+ const fileInputRef = useRef(null)
+ const typingRef = useRef(null)
+ const stopTypingRef = useRef(null)
+ const lastStartRef = useRef(0)
+ const initialLoadRef = useRef(true)
+ const knownMessageIdsRef = useRef(new Set())
+ const animatedMessageIdsRef = useRef(new Set())
+ const [animatedMessageIds, setAnimatedMessageIds] = useState({})
+ const prefersReducedMotion = usePrefersReducedMotion()
- useEffect(() => {
- return () => {
- for (const item of previewAttachments) {
- if (item.previewUrl) {
- URL.revokeObjectURL(item.previewUrl)
- }
- }
- }
- }, [previewAttachments])
+ const myParticipant = useMemo(() => (
+ conversation?.my_participant
+ ?? conversation?.all_participants?.find((participant) => participant.user_id === currentUserId)
+ ?? null
+ ), [conversation, currentUserId])
+
+ const participants = useMemo(() => conversation?.all_participants ?? [], [conversation])
+ const participantNames = useMemo(() => (
+ participants
+ .map((participant) => participant.user?.username)
+ .filter(Boolean)
+ ), [participants])
+
+ const filteredMessages = useMemo(() => {
+ const query = threadSearch.trim().toLowerCase()
+ if (!query) return messages
+ return messages.filter((message) => {
+ const sender = message.sender?.username?.toLowerCase() ?? ''
+ const text = message.body?.toLowerCase() ?? ''
+ const attachmentNames = (message.attachments ?? []).map((attachment) => attachment.original_name?.toLowerCase() ?? '').join(' ')
+ return sender.includes(query) || text.includes(query) || attachmentNames.includes(query)
+ })
+ }, [messages, threadSearch])
+
+ const conversationLabel = useMemo(() => {
+ if (!conversation) return 'Conversation'
+ if (conversation.type === 'group') return conversation.title ?? 'Group conversation'
+ return participants.find((participant) => participant.user_id !== currentUserId)?.user?.username ?? 'Direct message'
+ }, [conversation, currentUserId, participants])
+
+ const loadMessages = useCallback(async ({ cursor = null, append = false, silent = false } = {}) => {
+ if (append) setLoadingMore(true)
+ else if (!silent) setLoading(true)
- // ── Initial load ─────────────────────────────────────────────────────────
- const loadMessages = useCallback(async () => {
try {
- const data = await apiFetch(`/api/messages/${conversationId}`)
- const msgs = [...(data.data ?? [])].map(m => tagReactions(m, currentUserId))
- setMessages(msgs)
+ const url = cursor
+ ? `/api/messages/${conversationId}?cursor=${encodeURIComponent(cursor)}`
+ : `/api/messages/${conversationId}`
+ const data = await apiFetch(url)
+ const incoming = normalizeMessages(data.data ?? [], currentUserId)
setNextCursor(data.next_cursor ?? null)
- setLoading(false)
- if (msgs.length) latestIdRef.current = msgs[msgs.length - 1].id
- shouldAutoScrollRef.current = true
+ setMessages((prev) => append ? mergeMessageLists(incoming, prev) : incoming)
+ setError(null)
} catch (e) {
setError(e.message)
- setLoading(false)
- }
- }, [conversationId, currentUserId, apiFetch])
-
- useEffect(() => {
- setLoading(true)
- setMessages([])
- const storedDraft = window.localStorage.getItem(draftKey)
- setBody(storedDraft ?? '')
- loadMessages()
-
- if (!realtimeEnabled) {
- pollRef.current = setInterval(async () => {
- try {
- const data = await apiFetch(`/api/messages/${conversationId}`)
- const latestChunk = [...(data.data ?? [])].map(m => tagReactions(m, currentUserId))
- if (latestChunk.length && latestChunk[latestChunk.length - 1].id !== latestIdRef.current) {
- shouldAutoScrollRef.current = true
- setMessages(prev => mergeMessageLists(prev, latestChunk))
- latestIdRef.current = latestChunk[latestChunk.length - 1].id
- onConversationUpdated()
- }
- } catch (_) {}
- }, 10_000)
- }
-
- return () => {
- if (pollRef.current) clearInterval(pollRef.current)
- }
- }, [conversationId, draftKey, realtimeEnabled, currentUserId, apiFetch, loadMessages, onConversationUpdated])
-
- useEffect(() => {
- if (!realtimeEnabled) {
- typingPollRef.current = setInterval(async () => {
- try {
- const data = await apiFetch(`/api/messages/${conversationId}/typing`)
- setTypingUsers(data.typing ?? [])
- } catch (_) {}
- }, 2_000)
- }
-
- return () => {
- if (typingPollRef.current) clearInterval(typingPollRef.current)
- clearTimeout(typingStopTimerRef.current)
- apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
- }
- }, [conversationId, apiFetch, realtimeEnabled])
-
- useEffect(() => {
- const content = body.trim()
- if (!content) {
- clearTimeout(typingStopTimerRef.current)
- apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
- return
- }
-
- apiFetch(`/api/messages/${conversationId}/typing`, { method: 'POST' }).catch(() => {})
- clearTimeout(typingStopTimerRef.current)
- typingStopTimerRef.current = setTimeout(() => {
- apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
- }, 2500)
- }, [body, conversationId, apiFetch])
-
- useEffect(() => {
- if (body.trim()) {
- window.localStorage.setItem(draftKey, body)
- return
- }
-
- window.localStorage.removeItem(draftKey)
- }, [body, draftKey])
-
- // ── Scroll to bottom on first load and new messages ───────────────────────
- useEffect(() => {
- if (!loading && shouldAutoScrollRef.current) {
- bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
- shouldAutoScrollRef.current = false
- }
- }, [loading, messages.length])
-
- // ── Mark as read when thread is viewed ────────────────────────────────────
- useEffect(() => {
- if (!loading) {
- apiFetch(`/api/messages/${conversationId}/read`, { method: 'POST' })
- .then(() => onMarkRead(conversationId))
- .catch(() => {})
- }
- }, [loading, conversationId])
-
- // ── Load older messages ───────────────────────────────────────────────────
- const loadMore = useCallback(async () => {
- if (!nextCursor || loadingMore) return
- setLoadingMore(true)
- const container = threadRef.current
- const prevHeight = container?.scrollHeight ?? 0
-
- try {
- const data = await apiFetch(`/api/messages/${conversationId}?cursor=${nextCursor}`)
- const older = [...(data.data ?? [])].map(m => tagReactions(m, currentUserId))
- shouldAutoScrollRef.current = false
- setMessages(prev => [...older, ...prev])
- setNextCursor(data.next_cursor ?? null)
-
- requestAnimationFrame(() => {
- if (!container) return
- const newHeight = container.scrollHeight
- container.scrollTop = Math.max(0, newHeight - prevHeight + container.scrollTop)
- })
- } catch (_) {}
- setLoadingMore(false)
- }, [nextCursor, loadingMore, apiFetch, conversationId, currentUserId])
-
- const handleThreadScroll = useCallback((e) => {
- if (e.currentTarget.scrollTop < 120) {
- loadMore()
- }
- }, [loadMore])
-
- // ── Send message ──────────────────────────────────────────────────────────
- const handleSend = useCallback(async (e) => {
- e.preventDefault()
- const text = body.trim()
- if ((!text && attachments.length === 0) || sending) return
-
- setSending(true)
- const optimistic = {
- id: `opt-${Date.now()}`,
- sender_id: currentUserId,
- sender: { id: currentUserId, username: currentUsername },
- body: text,
- created_at: new Date().toISOString(),
- _optimistic: true,
- attachments: attachments.map((file, index) => ({
- id: `tmp-${Date.now()}-${index}`,
- type: file.type.startsWith('image/') ? 'image' : 'file',
- original_name: file.name,
- })),
- }
- setMessages(prev => [...prev, optimistic])
- setBody('')
- window.localStorage.removeItem(draftKey)
- shouldAutoScrollRef.current = true
- bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
-
- try {
- const formData = new FormData()
- formData.append('body', text)
- attachments.forEach(file => formData.append('attachments[]', file))
- setUploadProgress(0)
-
- const msg = await sendMessageWithProgress(`/api/messages/${conversationId}`, formData, (progress) => {
- setUploadProgress(progress)
- })
- setMessages(prev => prev.map(m => m.id === optimistic.id ? msg : m))
- latestIdRef.current = msg.id
- onConversationUpdated()
- setAttachments([])
- } catch (e) {
- setMessages(prev => prev.filter(m => m.id !== optimistic.id))
- setError(e.message)
} finally {
- setUploadProgress(null)
- setSending(false)
+ if (!silent) setLoading(false)
+ setLoadingMore(false)
}
- }, [body, attachments, sending, conversationId, currentUserId, currentUsername, apiFetch, onConversationUpdated, draftKey])
+ }, [apiFetch, conversationId, currentUserId])
- const handleKeyDown = useCallback((e) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault()
- handleSend(e)
+ const loadTyping = useCallback(async () => {
+ try {
+ const data = await apiFetch(`/api/messages/${conversationId}/typing`)
+ setTypingUsers(data.typing ?? [])
+ } catch {
+ setTypingUsers([])
}
- }, [handleSend])
-
- // ── Reaction ──────────────────────────────────────────────────────────────
- const handleReact = useCallback(async (messageId, emoji) => {
- try {
- await apiFetch(`/api/messages/${messageId}/reactions`, {
- method: 'POST',
- body: JSON.stringify({ reaction: emoji }),
- })
- // Optimistically add reaction with _iMine flag
- setMessages(prev => prev.map(m => {
- if (m.id !== messageId) return m
- const existing = (m.reactions ?? []).some(r => r._iMine && r.reaction === emoji)
- if (existing) return m
- return { ...m, reactions: [...(m.reactions ?? []), { reaction: emoji, user_id: currentUserId, _iMine: true }] }
- }))
- } catch (_) {}
- }, [currentUserId, apiFetch])
-
- const handleUnreact = useCallback(async (messageId, emoji) => {
- try {
- await apiFetch(`/api/messages/${messageId}/reactions`, {
- method: 'DELETE',
- body: JSON.stringify({ reaction: emoji }),
- })
- // Optimistically remove reaction
- setMessages(prev => prev.map(m => {
- if (m.id !== messageId) return m
- return { ...m, reactions: (m.reactions ?? []).filter(r => !(r._iMine && r.reaction === emoji)) }
- }))
- } catch (_) {}
- }, [apiFetch])
-
- const handleEdit = useCallback(async (messageId, newBody) => {
- const updated = await apiFetch(`/api/messages/message/${messageId}`, {
- method: 'PATCH',
- body: JSON.stringify({ body: newBody }),
- })
- setMessages(prev => prev.map(m => m.id === messageId ? { ...m, body: updated.body, edited_at: updated.edited_at } : m))
- }, [apiFetch])
-
- const handleReportMessage = useCallback(async (messageId) => {
- try {
- await apiFetch('/api/reports', {
- method: 'POST',
- body: JSON.stringify({
- target_type: 'message',
- target_id: messageId,
- reason: 'inappropriate',
- details: '',
- }),
- })
- } catch (e) {
- setError(e.message)
- }
- }, [apiFetch])
-
- const handlePickAttachments = useCallback((e) => {
- const next = Array.from(e.target.files ?? [])
- if (!next.length) return
- setAttachments(prev => [...prev, ...next].slice(0, 5))
- e.target.value = ''
- }, [])
-
- const removeAttachment = useCallback((index) => {
- setAttachments(prev => prev.filter((_, i) => i !== index))
- }, [])
-
- const togglePin = useCallback(async () => {
- const me = conversation?.all_participants?.find(p => p.user_id === currentUserId)
- const isPinned = !!me?.is_pinned
- const endpoint = isPinned ? 'unpin' : 'pin'
- try {
- await apiFetch(`/api/messages/${conversationId}/${endpoint}`, { method: 'POST' })
- onConversationUpdated()
- } catch (e) {
- setError(e.message)
- }
- }, [conversation, currentUserId, apiFetch, conversationId, onConversationUpdated])
-
- const toggleMute = useCallback(async () => {
- try {
- await apiFetch(`/api/messages/${conversationId}/mute`, { method: 'POST' })
- onConversationUpdated()
- } catch (e) {
- setError(e.message)
- }
- }, [apiFetch, conversationId, onConversationUpdated])
-
- const toggleArchive = useCallback(async () => {
- try {
- await apiFetch(`/api/messages/${conversationId}/archive`, { method: 'POST' })
- onConversationUpdated()
- } catch (e) {
- setError(e.message)
- }
- }, [apiFetch, conversationId, onConversationUpdated])
+ }, [apiFetch, conversationId])
useEffect(() => {
- let cancelled = false
- const q = threadSearch.trim()
- if (q.length < 2) {
- setThreadSearchResults([])
+ initialLoadRef.current = true
+ setMessages([])
+ setNextCursor(null)
+ setBody('')
+ setFiles([])
+ setDraftTitle(conversation?.title ?? '')
+ loadMessages()
+ loadTyping()
+ }, [conversation?.title, conversationId, loadMessages, loadTyping])
+
+ useEffect(() => {
+ apiFetch(`/api/messages/${conversationId}/read`, { method: 'POST' })
+ .then(() => onMarkRead?.(conversationId))
+ .catch(() => {})
+ }, [apiFetch, conversationId, onMarkRead])
+
+ useEffect(() => {
+ const timer = window.setInterval(() => {
+ loadTyping()
+ if (!realtimeEnabled) {
+ loadMessages({ silent: true })
+ }
+ }, realtimeEnabled ? 5000 : 8000)
+
+ return () => window.clearInterval(timer)
+ }, [loadMessages, loadTyping, realtimeEnabled])
+
+ useEffect(() => () => {
+ if (typingRef.current) window.clearTimeout(typingRef.current)
+ if (stopTypingRef.current) window.clearTimeout(stopTypingRef.current)
+ }, [])
+
+ useEffect(() => {
+ if (!listRef.current) return
+ if (initialLoadRef.current) {
+ listRef.current.scrollTop = listRef.current.scrollHeight
+ initialLoadRef.current = false
return
}
- const timer = setTimeout(async () => {
- try {
- const data = await apiFetch(`/api/messages/search?q=${encodeURIComponent(q)}&conversation_id=${conversationId}`)
- if (!cancelled) {
- setThreadSearchResults(data.data ?? [])
- }
- } catch (_) {
- if (!cancelled) {
- setThreadSearchResults([])
- }
+ const nearBottom = listRef.current.scrollHeight - listRef.current.scrollTop - listRef.current.clientHeight < 180
+ if (nearBottom) {
+ listRef.current.scrollTop = listRef.current.scrollHeight
+ }
+ }, [messages.length])
+
+ useEffect(() => {
+ const known = knownMessageIdsRef.current
+ const nextAnimatedIds = []
+
+ for (const message of messages) {
+ if (known.has(message.id)) continue
+
+ known.add(message.id)
+ if (!initialLoadRef.current && !message._optimistic && !animatedMessageIdsRef.current.has(message.id)) {
+ animatedMessageIdsRef.current.add(message.id)
+ nextAnimatedIds.push(message.id)
}
- }, 250)
-
- return () => {
- cancelled = true
- clearTimeout(timer)
}
- }, [threadSearch, conversationId, apiFetch])
- const jumpToMessage = useCallback((messageId) => {
- const target = document.getElementById(`message-${messageId}`)
- if (target) {
- target.scrollIntoView({ behavior: 'smooth', block: 'center' })
+ if (prefersReducedMotion || nextAnimatedIds.length === 0) return undefined
+
+ setAnimatedMessageIds((prev) => {
+ const next = { ...prev }
+ nextAnimatedIds.forEach((id) => {
+ next[id] = true
+ })
+ return next
+ })
+
+ const timer = window.setTimeout(() => {
+ setAnimatedMessageIds((prev) => {
+ const next = { ...prev }
+ nextAnimatedIds.forEach((id) => {
+ delete next[id]
+ })
+ return next
+ })
+ }, 2200)
+
+ return () => window.clearTimeout(timer)
+ }, [messages, prefersReducedMotion])
+
+ const handleBodyChange = useCallback((value) => {
+ setBody(value)
+
+ const now = Date.now()
+ if (now - lastStartRef.current > 2500) {
+ lastStartRef.current = now
+ apiFetch(`/api/messages/${conversationId}/typing`, { method: 'POST' }).catch(() => {})
}
+
+ if (stopTypingRef.current) window.clearTimeout(stopTypingRef.current)
+ stopTypingRef.current = window.setTimeout(() => {
+ apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
+ }, 1200)
+ }, [apiFetch, conversationId])
+
+ const handleFiles = useCallback((selectedFiles) => {
+ const nextFiles = Array.from(selectedFiles || []).slice(0, 5)
+ setFiles(nextFiles)
}, [])
- // ── Thread header label ───────────────────────────────────────────────────
- const threadLabel = conversation?.type === 'group'
- ? (conversation?.title ?? 'Group conversation')
- : (conversation?.all_participants?.find(p => p.user_id !== currentUserId)?.user?.username ?? 'Direct message')
- const myParticipant = conversation?.my_participant ?? conversation?.all_participants?.find(p => p.user_id === currentUserId)
- const otherParticipant = conversation?.all_participants?.find(p => p.user_id !== currentUserId)
- const otherLastReadAt = otherParticipant?.last_read_at ?? null
- const lastMessageId = messages[messages.length - 1]?.id ?? null
+ const handleSend = useCallback(async (e) => {
+ e.preventDefault()
- // ── Group date separators from messages ──────────────────────────────────
- const grouped = groupByDate(messages)
+ if (sending) return
+ const trimmed = body.trim()
+ if (!trimmed && files.length === 0) return
+
+ const optimisticId = `optimistic-${Date.now()}`
+ const optimisticMessage = normalizeMessage({
+ id: optimisticId,
+ body: trimmed,
+ sender: { id: currentUserId, username: currentUsername },
+ sender_id: currentUserId,
+ created_at: new Date().toISOString(),
+ attachments: files.map((file, index) => ({
+ id: `${optimisticId}-${index}`,
+ type: file.type?.startsWith('image/') ? 'image' : 'file',
+ original_name: file.name,
+ })),
+ reactions: [],
+ _optimistic: true,
+ }, currentUserId)
+
+ setMessages((prev) => mergeMessageLists(prev, [optimisticMessage]))
+ setBody('')
+ setFiles([])
+ if (fileInputRef.current) fileInputRef.current.value = ''
+ setSending(true)
+ setError(null)
+
+ const formData = new FormData()
+ if (trimmed) formData.append('body', trimmed)
+ files.forEach((file) => formData.append('attachments[]', file))
+
+ try {
+ const created = await apiFetch(`/api/messages/${conversationId}`, {
+ method: 'POST',
+ body: formData,
+ })
+
+ const normalized = normalizeMessage(created, currentUserId)
+ setMessages((prev) => prev.map((message) => message.id === optimisticId ? normalized : message))
+ onConversationUpdated?.()
+ } catch (err) {
+ setMessages((prev) => prev.filter((message) => message.id !== optimisticId))
+ setBody(trimmed)
+ setFiles(files)
+ setError(err.message)
+ } finally {
+ setSending(false)
+ apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
+ }
+ }, [apiFetch, body, conversationId, currentUserId, currentUsername, files, onConversationUpdated, sending])
+
+ const updateReactions = useCallback((messageId, summary) => {
+ setMessages((prev) => prev.map((message) => {
+ if (message.id !== messageId) return message
+ return {
+ ...message,
+ reactions: summaryToReactionArray(summary, currentUserId),
+ }
+ }))
+ }, [currentUserId])
+
+ const handleReact = useCallback(async (messageId, reaction) => {
+ const summary = await apiFetch(`/api/messages/${messageId}/reactions`, {
+ method: 'POST',
+ body: JSON.stringify({ reaction }),
+ })
+ updateReactions(messageId, summary)
+ }, [apiFetch, updateReactions])
+
+ const handleUnreact = useCallback(async (messageId, reaction) => {
+ const summary = await apiFetch(`/api/messages/${messageId}/reactions`, {
+ method: 'DELETE',
+ body: JSON.stringify({ reaction }),
+ })
+ updateReactions(messageId, summary)
+ }, [apiFetch, updateReactions])
+
+ const handleEdit = useCallback(async (messageId, nextBody) => {
+ const updated = await apiFetch(`/api/messages/message/${messageId}`, {
+ method: 'PATCH',
+ body: JSON.stringify({ body: nextBody }),
+ })
+
+ setMessages((prev) => prev.map((message) => (
+ message.id === messageId ? normalizeMessage({ ...message, ...updated }, currentUserId) : message
+ )))
+ onConversationUpdated?.()
+ }, [apiFetch, currentUserId, onConversationUpdated])
+
+ const handleDelete = useCallback(async (messageId) => {
+ await apiFetch(`/api/messages/message/${messageId}`, { method: 'DELETE' })
+ setMessages((prev) => prev.map((message) => (
+ message.id === messageId
+ ? { ...message, body: '', deleted_at: new Date().toISOString(), attachments: [] }
+ : message
+ )))
+ onConversationUpdated?.()
+ }, [apiFetch, onConversationUpdated])
+
+ const runConversationAction = useCallback(async (action, url, apply) => {
+ setBusyAction(action)
+ setError(null)
+ try {
+ const response = await apiFetch(url, { method: action === 'leave' ? 'DELETE' : 'POST' })
+ apply?.(response)
+ onConversationUpdated?.()
+ if (action === 'leave') onBack?.()
+ } catch (e) {
+ setError(e.message)
+ } finally {
+ setBusyAction(null)
+ }
+ }, [apiFetch, onBack, onConversationUpdated])
+
+ const handleRename = useCallback(async () => {
+ const title = draftTitle.trim()
+ if (!title || title === conversation?.title) return
+
+ setBusyAction('rename')
+ setError(null)
+
+ try {
+ await apiFetch(`/api/messages/${conversationId}/rename`, {
+ method: 'POST',
+ body: JSON.stringify({ title }),
+ })
+ onConversationUpdated?.()
+ } catch (e) {
+ setError(e.message)
+ } finally {
+ setBusyAction(null)
+ }
+ }, [apiFetch, conversation?.title, conversationId, draftTitle, onConversationUpdated])
+
+ const visibleMessages = filteredMessages
+ const messagesWithDecorators = useMemo(() => decorateMessages(visibleMessages, currentUserId, myParticipant?.last_read_at ?? null), [visibleMessages, currentUserId, myParticipant?.last_read_at])
+ const typingLabel = buildTypingLabel(typingUsers)
return (
-
- {/* Header */}
-
-
-
-
{threadLabel}
- {conversation?.type === 'group' && (
-
- {conversation.all_participants?.filter(p => !p.left_at).length ?? 0} members
+
+
+
+
+
+
+ Inbox
+
+
+
+
{conversationLabel}
+ {conversation?.type === 'group' ? Group : Direct}
+ {myParticipant?.is_pinned ? Pinned : null}
+ {myParticipant?.is_muted ? Muted : null}
+ {myParticipant?.is_archived ? Archived : null}
+
+
+
+ {conversation?.type === 'group'
+ ? `Participants: ${participantNames.join(', ')}`
+ : `Private thread with ${participantNames.find((name) => name !== currentUsername) ?? conversationLabel}.`}
- )}
- {typingUsers.length > 0 && (
-
{typingUsers.map(u => u.username).join(', ')} typing…
+
+
+
+
+
+
+ {conversation?.type === 'group' ? (
+
+ ) : null}
+
+
+
+
+
+
+
+ setThreadSearch(e.target.value)}
+ placeholder="Search inside this thread…"
+ className="w-full bg-transparent text-sm text-white outline-none placeholder:text-white/25"
+ />
+
+
+
+ {conversation?.type === 'group' ? (
+
+ setDraftTitle(e.target.value)}
+ placeholder="Rename group"
+ className="min-w-0 flex-1 rounded-2xl border border-white/[0.08] bg-black/15 px-3 py-2.5 text-sm text-white outline-none placeholder:text-white/25 focus:border-sky-400/30 focus:bg-black/25"
+ />
+
+
+ ) : (
+
+ {participantNames.length} participant{participantNames.length === 1 ? '' : 's'}
+
)}
-
-
-
-
+
-
-
setThreadSearch(e.target.value)}
- placeholder="Search in this conversation…"
- className="w-full rounded-lg bg-gray-100 dark:bg-gray-800 px-3 py-1.5 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 outline-none focus:ring-2 focus:ring-blue-500"
- />
- {threadSearch.trim().length >= 2 && (
-
- {threadSearchResults.length === 0 && (
-
No matches
- )}
- {threadSearchResults.map(item => (
-
- ))}
-
- )}
-
-
- {/* Messages */}
-
- {loadingMore && (
-
Loading older messages…
- )}
-
- {loading && (
-
Loading messages…
- )}
-
- {!loading && messages.length === 0 && (
-
- No messages yet. Say hello!
-
- )}
-
- {grouped.map(({ date, messages: dayMessages }) => (
-
-
-
- {date}
-
-
- {dayMessages.map((msg, idx) => (
-
-
-
- ))}
-
- ))}
-
-
-
-
- {/* Error */}
- {error && (
-
+ {error ? (
+
{error}
- )}
+ ) : null}
- {/* Compose */}
-
-
- {attachments.length > 0 && (
-
- {previewAttachments.map(({ file, previewUrl }, idx) => (
-
- {previewUrl && (
-

- )}
-
{file.name}
-
-
- ))}
-
- )}
-
- {sending && uploadProgress !== null && (
-
-
-
+
+ {nextCursor ? (
+
+
-
Uploading {uploadProgress}%
-
- )}
+ ) : null}
- {lightboxImage && (
-
setLightboxImage(null)}>
-

e.stopPropagation()}
- />
+ {loading ? (
+
+
Loading conversation…
+
+ ) : visibleMessages.length === 0 ? (
+
+
+
+
+
+
{threadSearch.trim() ? 'No matching messages' : 'No messages yet'}
+
+ {threadSearch.trim()
+ ? 'Try a different search term or clear the filter to see the full thread.'
+ : 'Start the conversation with a message, file, or quick note.'}
+
+
+
+ ) : (
+
+ {messagesWithDecorators.map(({ message, showUnreadMarker, dayLabel }, index) => {
+ const previous = messagesWithDecorators[index - 1]?.message
+ const next = messagesWithDecorators[index + 1]?.message
+ const showAvatar = !previous || previous.sender_id !== message.sender_id
+ const endsSequence = !next || next.sender_id !== message.sender_id
+ const seenText = isLastMineMessage(visibleMessages, index, currentUserId)
+ ? buildSeenText(participants, currentUserId)
+ : null
+
+ return (
+
+ {dayLabel ? (
+
+
+ {dayLabel}
+
+
+ ) : null}
+ {showUnreadMarker ?
: null}
+
+
+ )
+ })}
+
+ )}
+
+
+
+
+
+ {typingLabel ? {typingLabel} : Markdown supported. Images and files can be sent inline.}
+
+ {files.length > 0 ?
{files.length}/5 attachments : null}
- )}
+
+ {files.length > 0 ? (
+
+ {files.map((file, index) => (
+
+
+ {file.name}
+
+ ))}
+
+ ) : null}
+
+
+
+
+ {lightbox ? (
+
setLightbox(null)}>
+
e.stopPropagation()}>
+
+

+
+
+ ) : null}
)
}
-// ── Helpers ──────────────────────────────────────────────────────────────────
+function actionButtonClass(isBusy) {
+ return `inline-flex min-h-11 shrink-0 items-center gap-2 rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/65 transition hover:bg-white/[0.08] hover:text-white ${isBusy ? 'opacity-60' : ''}`
+}
-function tagReactions(msg, currentUserId) {
- if (!msg.reactions?.length) return msg
+function normalizeMessages(messages, currentUserId) {
+ return messages.map((message) => normalizeMessage(message, currentUserId))
+}
+
+function normalizeMessage(message, currentUserId) {
+ const reactionSummary = message.reaction_summary ?? null
return {
- ...msg,
- reactions: msg.reactions.map(r => ({ ...r, _iMine: r.user_id === currentUserId })),
+ ...message,
+ sender_id: message.sender_id ?? message.sender?.id ?? null,
+ reactions: reactionSummary ? summaryToReactionArray(reactionSummary, currentUserId) : normalizeReactionArray(message.reactions ?? [], currentUserId),
}
}
-function groupByDate(messages) {
- const map = new Map()
- for (const msg of messages) {
- const date = formatDate(msg.created_at)
- if (!map.has(date)) map.set(date, [])
- map.get(date).push(msg)
- }
- return Array.from(map.entries()).map(([date, messages]) => ({ date, messages }))
+function normalizeReactionArray(reactions, currentUserId) {
+ return reactions.map((reaction) => ({
+ ...reaction,
+ _iMine: reaction._iMine ?? reaction.user_id === currentUserId,
+ }))
+}
+
+function summaryToReactionArray(summary, currentUserId) {
+ if (!summary || typeof summary !== 'object') return []
+ const mine = Array.isArray(summary.me) ? summary.me : []
+ return Object.entries(summary)
+ .filter(([emoji]) => emoji !== 'me')
+ .flatMap(([emoji, count]) => Array.from({ length: Number(count) || 0 }, (_, index) => ({
+ id: `${emoji}-${index}`,
+ reaction: emoji,
+ user_id: mine.includes(emoji) && index === 0 ? currentUserId : null,
+ _iMine: mine.includes(emoji) && index === 0,
+ })))
}
function mergeMessageLists(existing, incoming) {
- const byId = new Map()
-
- for (const msg of existing) {
- byId.set(String(msg.id), msg)
+ const map = new Map()
+ for (const message of [...existing, ...incoming]) {
+ map.set(message.id, message)
}
+ return Array.from(map.values()).sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
+}
- for (const msg of incoming) {
- byId.set(String(msg.id), msg)
+function buildTypingLabel(users) {
+ if (!users || users.length === 0) return ''
+ const names = users.map((user) => `@${user.username}`)
+ if (names.length === 1) return `${names[0]} is typing…`
+ if (names.length === 2) return `${names[0]} and ${names[1]} are typing…`
+ return `${names.slice(0, 2).join(', ')} and ${names.length - 2} others are typing…`
+}
+
+function isLastMineMessage(messages, index, currentUserId) {
+ const current = messages[index]
+ if (!current || current.sender_id !== currentUserId) return false
+ for (let i = index + 1; i < messages.length; i += 1) {
+ if (messages[i].sender_id === currentUserId) return false
}
+ return true
+}
- return Array.from(byId.values()).sort((a, b) => {
- const at = new Date(a.created_at).getTime()
- const bt = new Date(b.created_at).getTime()
- if (at !== bt) return at - bt
- const aid = Number(a.id)
- const bid = Number(b.id)
- if (!Number.isNaN(aid) && !Number.isNaN(bid)) {
- return aid - bid
+function buildSeenText(participants, currentUserId) {
+ const seenBy = participants
+ .filter((participant) => participant.user_id !== currentUserId && participant.last_read_at)
+ .map((participant) => participant.user?.username)
+ .filter(Boolean)
+
+ if (seenBy.length === 0) return 'Sent'
+ if (seenBy.length === 1) return `Seen by @${seenBy[0]}`
+ return `Seen by ${seenBy.length} people`
+}
+
+function decorateMessages(messages, currentUserId, lastReadAt) {
+ let unreadMarked = false
+
+ return messages.map((message, index) => {
+ const previous = messages[index - 1]
+ const currentDay = dayKey(message.created_at)
+ const previousDay = previous ? dayKey(previous.created_at) : null
+ const shouldMarkUnread = !unreadMarked
+ && !!lastReadAt
+ && message.sender_id !== currentUserId
+ && !message.deleted_at
+ && new Date(message.created_at).getTime() > new Date(lastReadAt).getTime()
+
+ if (shouldMarkUnread) unreadMarked = true
+
+ return {
+ message,
+ showUnreadMarker: shouldMarkUnread,
+ dayLabel: currentDay !== previousDay ? formatDayLabel(message.created_at) : null,
}
- return String(a.id).localeCompare(String(b.id))
})
}
-function buildSeenText({ message, isMine, isDirect, isLastMessage, otherLastReadAt }) {
- if (!isDirect || !isMine || !isLastMessage || !otherLastReadAt || !message?.created_at) {
- return null
- }
-
- const seenAt = new Date(otherLastReadAt)
- const sentAt = new Date(message.created_at)
-
- if (Number.isNaN(seenAt.getTime()) || Number.isNaN(sentAt.getTime()) || seenAt < sentAt) {
- return null
- }
-
- return `Seen ${relativeTimeFromNow(otherLastReadAt)} ago`
+function dayKey(iso) {
+ const date = new Date(iso)
+ return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
}
-function relativeTimeFromNow(iso) {
- const seconds = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000))
- if (seconds < 60) return `${seconds}s`
- if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
- if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`
- return `${Math.floor(seconds / 86400)}d`
-}
-
-function formatDate(iso) {
- const d = new Date(iso)
+function formatDayLabel(iso) {
+ const date = new Date(iso)
const today = new Date()
- const yesterday = new Date(today)
- yesterday.setDate(today.getDate() - 1)
+ const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate())
+ const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
+ const diffDays = Math.round((startOfToday.getTime() - startOfDate.getTime()) / 86400000)
- if (isSameDay(d, today)) return 'Today'
- if (isSameDay(d, yesterday)) return 'Yesterday'
- return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
-}
+ if (diffDays === 0) return 'Today'
+ if (diffDays === 1) return 'Yesterday'
-function isSameDay(a, b) {
- return a.getFullYear() === b.getFullYear() &&
- a.getMonth() === b.getMonth() &&
- a.getDate() === b.getDate()
-}
-
-function isImageLike(file) {
- return file?.type?.startsWith('image/')
-}
-
-function getCsrf() {
- return document.querySelector('meta[name="csrf-token"]')?.content ?? ''
-}
-
-function sendMessageWithProgress(url, formData, onProgress) {
- return new Promise((resolve, reject) => {
- const xhr = new XMLHttpRequest()
- xhr.open('POST', url)
- xhr.setRequestHeader('X-CSRF-TOKEN', getCsrf())
- xhr.setRequestHeader('Accept', 'application/json')
-
- xhr.upload.onprogress = (event) => {
- if (!event.lengthComputable) return
- const progress = Math.max(0, Math.min(100, Math.round((event.loaded / event.total) * 100)))
- onProgress(progress)
- }
-
- xhr.onload = () => {
- try {
- const json = JSON.parse(xhr.responseText || '{}')
- if (xhr.status >= 200 && xhr.status < 300) {
- resolve(json)
- return
- }
- reject(new Error(json.message || `HTTP ${xhr.status}`))
- } catch (_) {
- reject(new Error(`HTTP ${xhr.status}`))
- }
- }
-
- xhr.onerror = () => reject(new Error('Network error'))
- xhr.send(formData)
+ return date.toLocaleDateString(undefined, {
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
})
}
+
+function UnreadMarker({ prefersReducedMotion }) {
+ const [isVisible, setIsVisible] = useState(prefersReducedMotion)
+
+ useEffect(() => {
+ if (prefersReducedMotion) return undefined
+
+ setIsVisible(false)
+ const frame = window.requestAnimationFrame(() => setIsVisible(true))
+ return () => window.cancelAnimationFrame(frame)
+ }, [prefersReducedMotion])
+
+ return (
+
+
+
+ Unread messages
+
+
+
+ )
+}
+
+function usePrefersReducedMotion() {
+ const [prefersReducedMotion, setPrefersReducedMotion] = useState(false)
+
+ useEffect(() => {
+ const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
+ const handleChange = () => setPrefersReducedMotion(mediaQuery.matches)
+
+ handleChange()
+ mediaQuery.addEventListener('change', handleChange)
+
+ return () => mediaQuery.removeEventListener('change', handleChange)
+ }, [])
+
+ return prefersReducedMotion
+}
diff --git a/resources/js/components/messaging/MessageBubble.jsx b/resources/js/components/messaging/MessageBubble.jsx
index 86b72aee..9e6ed31e 100644
--- a/resources/js/components/messaging/MessageBubble.jsx
+++ b/resources/js/components/messaging/MessageBubble.jsx
@@ -3,32 +3,38 @@ import ReactMarkdown from 'react-markdown'
const QUICK_REACTIONS = ['👍', '❤️', '🔥', '😂', '👏', '😮']
-/**
- * Individual message bubble with:
- * - Markdown rendering (no raw HTML allowed)
- * - Hover reaction picker + unreact on click
- * - Inline edit for own messages
- * - Soft-delete display
- */
-export default function MessageBubble({ message, isMine, showAvatar, onReact, onUnreact, onEdit, onReport = null, onOpenImage = null, seenText = null }) {
+export default function MessageBubble({ message, isMine, showAvatar, endsSequence = true, isNewlyArrived = false, prefersReducedMotion = false, onReact, onUnreact, onEdit, onDelete = null, onReport = null, onOpenImage = null, seenText = null }) {
const [showPicker, setShowPicker] = useState(false)
- const [editing, setEditing] = useState(false)
- const [editBody, setEditBody] = useState(message.body ?? '')
+ const [showActions, setShowActions] = useState(false)
+ const [editing, setEditing] = useState(false)
+ const [editBody, setEditBody] = useState(message.body ?? '')
const [savingEdit, setSavingEdit] = useState(false)
- const editRef = useRef(null)
+ const [isArrivalVisible, setIsArrivalVisible] = useState(true)
+ const editRef = useRef(null)
const isDeleted = !!message.deleted_at
- const isEdited = !!message.edited_at
- const username = message.sender?.username ?? 'Unknown'
- const time = formatTime(message.created_at)
+ const isEdited = !!message.edited_at
+ const username = message.sender?.username ?? 'Unknown'
+ const time = formatTime(message.created_at)
+ const initials = username.charAt(0).toUpperCase()
- // Focus textarea when entering edit mode
useEffect(() => {
if (editing) {
editRef.current?.focus()
editRef.current?.setSelectionRange(editBody.length, editBody.length)
}
- }, [editing])
+ }, [editing, editBody.length])
+
+ useEffect(() => {
+ if (prefersReducedMotion || !isNewlyArrived) {
+ setIsArrivalVisible(true)
+ return undefined
+ }
+
+ setIsArrivalVisible(false)
+ const frame = window.requestAnimationFrame(() => setIsArrivalVisible(true))
+ return () => window.cancelAnimationFrame(frame)
+ }, [isNewlyArrived, prefersReducedMotion])
const reactionGroups = groupReactions(message.reactions ?? [])
@@ -45,50 +51,77 @@ export default function MessageBubble({ message, isMine, showAvatar, onReact, on
}
const handleEditKeyDown = (e) => {
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSaveEdit() }
- if (e.key === 'Escape') { setEditing(false); setEditBody(message.body) }
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ handleSaveEdit()
+ }
+
+ if (e.key === 'Escape') {
+ setEditing(false)
+ setEditBody(message.body)
+ }
}
return (
!isDeleted && !editing && setShowPicker(true)}
- onMouseLeave={() => setShowPicker(false)}
+ onMouseLeave={() => {
+ setShowPicker(false)
+ setShowActions(false)
+ }}
>
- {/* Avatar */}
-
-
- {username.charAt(0).toUpperCase()}
+
-
- {/* Sender name & time */}
- {showAvatar && (
-
-
{username}
-
{time}
+
+ {showAvatar ? (
+
+ {username}
+ {time}
- )}
+ ) : null}
- {/* Bubble */}
+ {!editing && !isDeleted ? (
+
+ ) : null}
+
{editing ? (