messages implemented
This commit is contained in:
123
resources/js/components/messaging/ConversationList.jsx
Normal file
123
resources/js/components/messaging/ConversationList.jsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Left panel: searchable, paginated list of conversations.
|
||||
*/
|
||||
export default function ConversationList({ conversations, loading, activeId, currentUserId, onSelect }) {
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase().trim()
|
||||
if (!q) return conversations
|
||||
return conversations.filter(conv => {
|
||||
const label = convLabel(conv, currentUserId).toLowerCase()
|
||||
const last = (conv.latest_message?.[0]?.body ?? '').toLowerCase()
|
||||
return label.includes(q) || last.includes(q)
|
||||
})
|
||||
}, [conversations, search, currentUserId])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
{/* Search */}
|
||||
<div className="px-3 py-2 border-b border-gray-100 dark:border-gray-800">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search conversations…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<ul className="flex-1 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{loading && (
|
||||
<li className="px-4 py-8 text-center text-sm text-gray-400">Loading…</li>
|
||||
)}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<li className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
{search ? 'No matches found.' : 'No conversations yet.'}
|
||||
</li>
|
||||
)}
|
||||
{filtered.map(conv => (
|
||||
<ConversationRow
|
||||
key={conv.id}
|
||||
conv={conv}
|
||||
isActive={conv.id === activeId}
|
||||
currentUserId={currentUserId}
|
||||
onClick={() => onSelect(conv.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConversationRow({ conv, isActive, currentUserId, onClick }) {
|
||||
const label = convLabel(conv, currentUserId)
|
||||
const lastMsg = Array.isArray(conv.latest_message) ? conv.latest_message[0] : conv.latest_message
|
||||
const preview = lastMsg ? truncate(lastMsg.body, 60) : 'No messages yet'
|
||||
const unread = conv.unread_count ?? 0
|
||||
const myParticipant = conv.all_participants?.find(p => p.user_id === currentUserId)
|
||||
const isArchived = myParticipant?.is_archived ?? false
|
||||
const isPinned = myParticipant?.is_pinned ?? false
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left px-4 py-3 flex gap-3 hover:bg-gray-50 dark:hover:bg-gray-800/60 transition-colors ${isActive ? 'bg-blue-50 dark:bg-blue-900/30' : ''} ${isArchived ? 'opacity-60' : ''}`}
|
||||
>
|
||||
{/* Avatar placeholder */}
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-gradient-to-br from-blue-400 to-purple-500 flex items-center justify-center text-white text-sm font-medium">
|
||||
{label.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
{isPinned && <span className="text-xs" title="Pinned">📌</span>}
|
||||
<span className={`text-sm font-medium truncate ${isActive ? 'text-blue-700 dark:text-blue-300' : 'text-gray-900 dark:text-gray-100'}`}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{conv.last_message_at && (
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">
|
||||
{relativeTime(conv.last_message_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-1 mt-0.5">
|
||||
<span className="text-xs text-gray-400 truncate">{preview}</span>
|
||||
{unread > 0 && (
|
||||
<span className="flex-shrink-0 min-w-[1.25rem] h-5 rounded-full bg-blue-500 text-white text-xs font-medium flex items-center justify-center px-1">
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function convLabel(conv, currentUserId) {
|
||||
if (conv.type === 'group') return conv.title ?? 'Group'
|
||||
const other = conv.all_participants?.find(p => p.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
|
||||
}
|
||||
|
||||
function relativeTime(iso) {
|
||||
const diff = (Date.now() - new Date(iso).getTime()) / 1000
|
||||
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`
|
||||
}
|
||||
587
resources/js/components/messaging/ConversationThread.jsx
Normal file
587
resources/js/components/messaging/ConversationThread.jsx
Normal file
@@ -0,0 +1,587 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import MessageBubble from './MessageBubble'
|
||||
|
||||
/**
|
||||
* Right panel: scrollable thread of messages with send form.
|
||||
*/
|
||||
export default function ConversationThread({
|
||||
conversationId,
|
||||
conversation,
|
||||
currentUserId,
|
||||
currentUsername,
|
||||
apiFetch,
|
||||
onBack,
|
||||
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 [loadingMore, setLoadingMore] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [attachments, setAttachments] = useState([])
|
||||
const [typingUsers, setTypingUsers] = useState([])
|
||||
const [threadSearch, setThreadSearch] = useState('')
|
||||
const [threadSearchResults, setThreadSearchResults] = useState([])
|
||||
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}`
|
||||
|
||||
// ── 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)
|
||||
setNextCursor(data.next_cursor ?? null)
|
||||
setLoading(false)
|
||||
if (msgs.length) latestIdRef.current = msgs[msgs.length - 1].id
|
||||
shouldAutoScrollRef.current = true
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
setLoading(false)
|
||||
}
|
||||
}, [conversationId, currentUserId, apiFetch])
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
setMessages([])
|
||||
const storedDraft = window.localStorage.getItem(draftKey)
|
||||
setBody(storedDraft ?? '')
|
||||
loadMessages()
|
||||
|
||||
// Phase 1 polling: check new messages every 10 seconds
|
||||
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 () => clearInterval(pollRef.current)
|
||||
}, [conversationId, draftKey])
|
||||
|
||||
useEffect(() => {
|
||||
typingPollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const data = await apiFetch(`/api/messages/${conversationId}/typing`)
|
||||
setTypingUsers(data.typing ?? [])
|
||||
} catch (_) {}
|
||||
}, 2_000)
|
||||
|
||||
return () => {
|
||||
clearInterval(typingPollRef.current)
|
||||
clearTimeout(typingStopTimerRef.current)
|
||||
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
|
||||
}
|
||||
}, [conversationId, apiFetch])
|
||||
|
||||
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))
|
||||
|
||||
const msg = await apiFetch(`/api/messages/${conversationId}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
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 {
|
||||
setSending(false)
|
||||
}
|
||||
}, [body, attachments, sending, conversationId, currentUserId, currentUsername, apiFetch, onConversationUpdated, draftKey])
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend(e)
|
||||
}
|
||||
}, [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])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const q = threadSearch.trim()
|
||||
if (q.length < 2) {
|
||||
setThreadSearchResults([])
|
||||
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([])
|
||||
}
|
||||
}
|
||||
}, 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' })
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ── 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 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
|
||||
|
||||
// ── Group date separators from messages ──────────────────────────────────
|
||||
const grouped = groupByDate(messages)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="sm:hidden p-1 text-gray-500 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<p className="font-medium text-sm text-gray-900 dark:text-gray-100">{threadLabel}</p>
|
||||
{conversation?.type === 'group' && (
|
||||
<p className="text-xs text-gray-400">
|
||||
{conversation.all_participants?.filter(p => !p.left_at).length ?? 0} members
|
||||
</p>
|
||||
)}
|
||||
{typingUsers.length > 0 && (
|
||||
<p className="text-xs text-blue-400">{typingUsers.map(u => u.username).join(', ')} typing…</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePin}
|
||||
className="ml-auto text-xs px-2 py-1 rounded border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
{conversation?.my_participant?.is_pinned ? 'Unpin' : 'Pin'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<input
|
||||
type="search"
|
||||
value={threadSearch}
|
||||
onChange={e => 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 && (
|
||||
<div className="mt-2 max-h-28 overflow-y-auto rounded border border-gray-200 dark:border-gray-700">
|
||||
{threadSearchResults.length === 0 && (
|
||||
<p className="px-2 py-1 text-xs text-gray-400">No matches</p>
|
||||
)}
|
||||
{threadSearchResults.map(item => (
|
||||
<button
|
||||
key={`thread-search-${item.id}`}
|
||||
onClick={() => jumpToMessage(item.id)}
|
||||
className="w-full text-left px-2 py-1 text-xs hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<span className="text-gray-500">@{item.sender?.username ?? 'unknown'}: </span>
|
||||
<span className="text-gray-800 dark:text-gray-200">{item.body || '(attachment)'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div ref={threadRef} onScroll={handleThreadScroll} className="flex-1 overflow-y-auto px-4 py-4 space-y-1">
|
||||
{loadingMore && (
|
||||
<div className="text-center py-2 text-xs text-gray-400">Loading older messages…</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="text-center text-sm text-gray-400 py-12">Loading messages…</div>
|
||||
)}
|
||||
|
||||
{!loading && messages.length === 0 && (
|
||||
<div className="text-center text-sm text-gray-400 py-12">
|
||||
No messages yet. Say hello!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{grouped.map(({ date, messages: dayMessages }) => (
|
||||
<React.Fragment key={date}>
|
||||
<div className="flex items-center gap-3 my-4">
|
||||
<hr className="flex-1 border-gray-200 dark:border-gray-700" />
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">{date}</span>
|
||||
<hr className="flex-1 border-gray-200 dark:border-gray-700" />
|
||||
</div>
|
||||
{dayMessages.map((msg, idx) => (
|
||||
<div key={msg.id} id={`message-${msg.id}`}>
|
||||
<MessageBubble
|
||||
message={msg}
|
||||
isMine={msg.sender_id === currentUserId}
|
||||
showAvatar={idx === 0 || dayMessages[idx - 1]?.sender_id !== msg.sender_id}
|
||||
onReact={handleReact}
|
||||
onUnreact={handleUnreact}
|
||||
onEdit={handleEdit}
|
||||
onReport={handleReportMessage}
|
||||
seenText={buildSeenText({
|
||||
message: msg,
|
||||
isMine: msg.sender_id === currentUserId,
|
||||
isDirect: conversation?.type === 'direct',
|
||||
isLastMessage: msg.id === lastMessageId,
|
||||
otherLastReadAt,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mx-4 mb-2 px-3 py-2 text-xs text-red-600 bg-red-50 dark:bg-red-900/30 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compose */}
|
||||
<form onSubmit={handleSend} className="flex gap-2 px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handlePickAttachments}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex-shrink-0 rounded-xl border border-gray-200 dark:border-gray-700 px-3 py-2 text-sm text-gray-600 dark:text-gray-300"
|
||||
title="Attach files"
|
||||
>
|
||||
📎
|
||||
</button>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Write a message… (Enter to send, Shift+Enter for new line)"
|
||||
rows={1}
|
||||
maxLength={5000}
|
||||
className="flex-1 resize-none rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 outline-none focus:ring-2 focus:ring-blue-500 max-h-32 overflow-y-auto"
|
||||
style={{ minHeight: '2.5rem' }}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={(!body.trim() && attachments.length === 0) || sending}
|
||||
className="flex-shrink-0 rounded-xl bg-blue-500 hover:bg-blue-600 disabled:opacity-40 text-white px-4 py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="px-4 pb-3 flex flex-wrap gap-2">
|
||||
{attachments.map((file, idx) => (
|
||||
<div key={`${file.name}-${idx}`} className="inline-flex items-center gap-2 rounded-lg bg-gray-100 dark:bg-gray-800 px-2 py-1 text-xs text-gray-700 dark:text-gray-300">
|
||||
<span className="truncate max-w-[220px]">{file.name}</span>
|
||||
<button type="button" onClick={() => removeAttachment(idx)} className="text-red-500">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function tagReactions(msg, currentUserId) {
|
||||
if (!msg.reactions?.length) return msg
|
||||
return {
|
||||
...msg,
|
||||
reactions: msg.reactions.map(r => ({ ...r, _iMine: r.user_id === 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 mergeMessageLists(existing, incoming) {
|
||||
const byId = new Map()
|
||||
|
||||
for (const msg of existing) {
|
||||
byId.set(String(msg.id), msg)
|
||||
}
|
||||
|
||||
for (const msg of incoming) {
|
||||
byId.set(String(msg.id), msg)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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 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)
|
||||
const today = new Date()
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(today.getDate() - 1)
|
||||
|
||||
if (isSameDay(d, today)) return 'Today'
|
||||
if (isSameDay(d, yesterday)) return 'Yesterday'
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function isSameDay(a, b) {
|
||||
return a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
}
|
||||
252
resources/js/components/messaging/MessageBubble.jsx
Normal file
252
resources/js/components/messaging/MessageBubble.jsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
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, seenText = null }) {
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editBody, setEditBody] = useState(message.body ?? '')
|
||||
const [savingEdit, setSavingEdit] = useState(false)
|
||||
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)
|
||||
|
||||
// Focus textarea when entering edit mode
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
editRef.current?.focus()
|
||||
editRef.current?.setSelectionRange(editBody.length, editBody.length)
|
||||
}
|
||||
}, [editing])
|
||||
|
||||
const reactionGroups = groupReactions(message.reactions ?? [])
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
const trimmed = editBody.trim()
|
||||
if (!trimmed || trimmed === message.body || savingEdit) return
|
||||
setSavingEdit(true)
|
||||
try {
|
||||
await onEdit(message.id, trimmed)
|
||||
setEditing(false)
|
||||
} finally {
|
||||
setSavingEdit(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSaveEdit() }
|
||||
if (e.key === 'Escape') { setEditing(false); setEditBody(message.body) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group flex gap-2 items-end ${isMine ? 'flex-row-reverse' : 'flex-row'} ${showAvatar ? 'mt-3' : 'mt-0.5'}`}
|
||||
onMouseEnter={() => !isDeleted && !editing && setShowPicker(true)}
|
||||
onMouseLeave={() => setShowPicker(false)}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className={`flex-shrink-0 w-7 h-7 ${showAvatar ? 'visible' : 'invisible'}`}>
|
||||
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-indigo-400 to-pink-400 flex items-center justify-center text-white text-xs font-medium select-none">
|
||||
{username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`max-w-[75%] flex flex-col ${isMine ? 'items-end' : 'items-start'}`}>
|
||||
{/* Sender name & time */}
|
||||
{showAvatar && (
|
||||
<div className={`flex items-center gap-1.5 mb-1 ${isMine ? 'flex-row-reverse' : ''}`}>
|
||||
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">{username}</span>
|
||||
<span className="text-xs text-gray-400">{time}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bubble */}
|
||||
<div className="relative">
|
||||
{editing ? (
|
||||
<div className="w-72">
|
||||
<textarea
|
||||
ref={editRef}
|
||||
value={editBody}
|
||||
onChange={e => setEditBody(e.target.value)}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
rows={3}
|
||||
maxLength={5000}
|
||||
className="w-full resize-none rounded-xl border border-blue-400 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex gap-2 mt-1 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditing(false); setEditBody(message.body) }}
|
||||
className="text-xs text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveEdit}
|
||||
disabled={savingEdit || !editBody.trim() || editBody.trim() === message.body}
|
||||
className="text-xs text-blue-500 hover:text-blue-700 font-medium disabled:opacity-40"
|
||||
>
|
||||
{savingEdit ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`px-3 py-2 rounded-2xl text-sm leading-relaxed break-words ${
|
||||
isMine
|
||||
? 'bg-blue-500 text-white rounded-br-sm'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-sm'
|
||||
} ${isDeleted ? 'italic opacity-60' : ''} ${message._optimistic ? 'opacity-70' : ''}`}
|
||||
>
|
||||
{isDeleted ? (
|
||||
<span>This message was deleted.</span>
|
||||
) : (
|
||||
<>
|
||||
{message.attachments?.length > 0 && (
|
||||
<div className="mb-2 space-y-1">
|
||||
{message.attachments.map(att => (
|
||||
<div key={att.id}>
|
||||
{att.type === 'image' ? (
|
||||
<a href={`/messages/attachments/${att.id}`} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
src={`/messages/attachments/${att.id}`}
|
||||
alt={att.original_name}
|
||||
className="max-h-44 rounded-lg border border-white/20"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href={`/messages/attachments/${att.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs ${isMine ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100'}`}
|
||||
>
|
||||
📎 {att.original_name}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={`prose prose-sm max-w-none prose-p:my-0 prose-pre:my-1 ${isMine ? 'prose-invert' : 'dark:prose-invert'}`}>
|
||||
<ReactMarkdown
|
||||
allowedElements={['p', 'strong', 'em', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'a', 'br']}
|
||||
unwrapDisallowed
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer"
|
||||
className={isMine ? 'text-blue-200 underline' : 'text-blue-600 dark:text-blue-400 underline'}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
code: ({ children, className }) => className
|
||||
? <code className={`${className} text-xs`}>{children}</code>
|
||||
: <code className={`px-1 py-0.5 rounded text-xs ${isMine ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'}`}>{children}</code>,
|
||||
}}
|
||||
>
|
||||
{message.body}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
{isEdited && (
|
||||
<span className={`text-xs ml-1 ${isMine ? 'text-blue-200' : 'text-gray-400'}`}>(edited)</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover action strip: reactions + edit pencil */}
|
||||
{showPicker && !editing && !isDeleted && (
|
||||
<div
|
||||
className={`absolute bottom-full mb-1 ${isMine ? 'right-0' : 'left-0'} flex items-center gap-1 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-full px-2 py-1 shadow-md z-10 whitespace-nowrap`}
|
||||
onMouseEnter={() => setShowPicker(true)}
|
||||
onMouseLeave={() => setShowPicker(false)}
|
||||
>
|
||||
{QUICK_REACTIONS.map(emoji => (
|
||||
<button key={emoji} onClick={() => onReact(message.id, emoji)}
|
||||
className="text-base hover:scale-125 transition-transform" title={`React ${emoji}`}>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
{isMine && (
|
||||
<button
|
||||
onClick={() => { setEditing(true); setShowPicker(false) }}
|
||||
className="ml-1 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
title="Edit message"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{!isMine && onReport && (
|
||||
<button
|
||||
onClick={() => { onReport(message.id); setShowPicker(false) }}
|
||||
className="ml-1 text-gray-400 hover:text-red-500"
|
||||
title="Report message"
|
||||
>
|
||||
⚑
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reactions bar */}
|
||||
{reactionGroups.length > 0 && !isDeleted && (
|
||||
<div className={`flex flex-wrap gap-1 mt-1 ${isMine ? 'justify-end' : 'justify-start'}`}>
|
||||
{reactionGroups.map(({ emoji, count, iReacted }) => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => iReacted ? onUnreact(message.id, emoji) : onReact(message.id, emoji)}
|
||||
title={iReacted ? 'Remove reaction' : `React ${emoji}`}
|
||||
className={`flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-xs transition-colors ${
|
||||
iReacted
|
||||
? 'bg-blue-100 dark:bg-blue-900/40 border border-blue-300 dark:border-blue-700 text-blue-700 dark:text-blue-300'
|
||||
: 'bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span>{emoji}</span>
|
||||
<span>{count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMine && seenText && (
|
||||
<div className="mt-1 text-[11px] text-gray-400 dark:text-gray-500">
|
||||
{seenText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatTime(iso) {
|
||||
return new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function groupReactions(reactions) {
|
||||
const map = new Map()
|
||||
for (const r of reactions) {
|
||||
if (!map.has(r.reaction)) map.set(r.reaction, { count: 0, iReacted: false })
|
||||
const entry = map.get(r.reaction)
|
||||
entry.count++
|
||||
if (r._iMine) entry.iReacted = true
|
||||
}
|
||||
return Array.from(map.entries()).map(([emoji, { count, iReacted }]) => ({ emoji, count, iReacted }))
|
||||
}
|
||||
177
resources/js/components/messaging/NewConversationModal.jsx
Normal file
177
resources/js/components/messaging/NewConversationModal.jsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
|
||||
/**
|
||||
* Modal for creating a new direct or group conversation.
|
||||
*/
|
||||
export default function NewConversationModal({ currentUserId, apiFetch, onCreated, onClose }) {
|
||||
const [type, setType] = useState('direct')
|
||||
const [recipientInput, setRecipient] = useState('')
|
||||
const [groupTitle, setGroupTitle] = useState('')
|
||||
const [participantInputs, setParticipantInputs] = useState(['', ''])
|
||||
const [body, setBody] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const addParticipant = () => setParticipantInputs(p => [...p, ''])
|
||||
const updateParticipant = (i, val) =>
|
||||
setParticipantInputs(p => p.map((v, idx) => idx === i ? val : v))
|
||||
const removeParticipant = (i) =>
|
||||
setParticipantInputs(p => p.filter((_, idx) => idx !== i))
|
||||
|
||||
const handleSubmit = useCallback(async (e) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setSending(true)
|
||||
|
||||
try {
|
||||
// Resolve usernames to IDs via the search API
|
||||
let payload = { type, body }
|
||||
|
||||
if (type === 'direct') {
|
||||
const user = await resolveUsername(recipientInput.trim(), apiFetch)
|
||||
payload.recipient_id = user.id
|
||||
} else {
|
||||
const resolved = await Promise.all(
|
||||
participantInputs
|
||||
.map(p => p.trim())
|
||||
.filter(Boolean)
|
||||
.map(u => resolveUsername(u, apiFetch))
|
||||
)
|
||||
payload.participant_ids = resolved.map(u => u.id)
|
||||
payload.title = groupTitle.trim()
|
||||
}
|
||||
|
||||
const conv = await apiFetch('/api/messages/conversation', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
onCreated(conv)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}, [type, body, recipientInput, groupTitle, participantInputs, apiFetch, onCreated])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-xl w-full max-w-md p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">New Message</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Type toggle */}
|
||||
<div className="flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden mb-4">
|
||||
{['direct', 'group'].map(t => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setType(t)}
|
||||
className={`flex-1 py-1.5 text-sm font-medium transition-colors ${
|
||||
type === t
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{t === 'direct' ? '1:1 Message' : 'Group'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
{type === 'direct' ? (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Recipient username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={recipientInput}
|
||||
onChange={e => setRecipient(e.target.value)}
|
||||
placeholder="username"
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Group name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={groupTitle}
|
||||
onChange={e => setGroupTitle(e.target.value)}
|
||||
placeholder="Group name"
|
||||
required
|
||||
maxLength={120}
|
||||
className="w-full rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Participants (usernames)</label>
|
||||
{participantInputs.map((val, i) => (
|
||||
<div key={i} className="flex gap-2 mb-1">
|
||||
<input
|
||||
type="text"
|
||||
value={val}
|
||||
onChange={e => updateParticipant(i, e.target.value)}
|
||||
placeholder={`Username ${i + 1}`}
|
||||
required
|
||||
className="flex-1 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 px-3 py-1.5 text-sm text-gray-900 dark:text-gray-100 outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
{participantInputs.length > 2 && (
|
||||
<button type="button" onClick={() => removeParticipant(i)} className="text-gray-400 hover:text-red-500">×</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={addParticipant} className="text-xs text-blue-500 hover:text-blue-700 mt-1">
|
||||
+ Add participant
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Message</label>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
placeholder="Write your message…"
|
||||
required
|
||||
rows={3}
|
||||
maxLength={5000}
|
||||
className="w-full resize-none rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 bg-red-50 dark:bg-red-900/30 rounded px-2 py-1">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-end pt-1">
|
||||
<button type="button" onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900">Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sending}
|
||||
className="px-4 py-2 rounded-xl bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{sending ? 'Sending…' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Resolve username to user object via search API ───────────────────────────
|
||||
async function resolveUsername(username, apiFetch) {
|
||||
const data = await apiFetch(`/api/search/users?q=${encodeURIComponent(username)}&limit=1`)
|
||||
const user = data?.data?.[0] ?? data?.[0]
|
||||
if (!user) throw new Error(`User "${username}" not found.`)
|
||||
return user
|
||||
}
|
||||
Reference in New Issue
Block a user