Files
SkinbaseNova/resources/js/Pages/Messages/Index.jsx

651 lines
23 KiB
JavaScript

import React, { useState, useEffect, useCallback } from 'react'
import { createRoot } from 'react-dom/client'
import { getEcho } from '../../bootstrap'
import ConversationList from '../../components/messaging/ConversationList'
import ConversationThread from '../../components/messaging/ConversationThread'
import NewConversationModal from '../../components/messaging/NewConversationModal'
function getCsrf() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? ''
}
async function apiFetch(url, options = {}) {
const isFormData = options.body instanceof FormData
const socketId = getEcho()?.socketId?.()
const headers = {
'X-CSRF-TOKEN': getCsrf(),
Accept: 'application/json',
...options.headers,
}
if (socketId) {
headers['X-Socket-ID'] = socketId
}
if (!isFormData) {
headers['Content-Type'] = 'application/json'
}
const res = await fetch(url, {
headers,
...options,
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.message ?? `HTTP ${res.status}`)
}
return res.json()
}
function relativeTime(iso) {
if (!iso) return 'No activity yet'
const diff = (Date.now() - new Date(iso).getTime()) / 1000
if (diff < 60) return 'Just now'
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
return new Date(iso).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
})
}
function buildSearchPreview(item) {
const body = (item?.body || '').trim()
return body || '(attachment only)'
}
function MessagesPage({ userId, username, activeConversationId: initialId }) {
const [conversations, setConversations] = useState([])
const [loadingConvs, setLoadingConvs] = useState(true)
const [activeId, setActiveId] = useState(initialId ?? null)
const [realtimeEnabled, setRealtimeEnabled] = useState(false)
const [realtimeStatus, setRealtimeStatus] = useState('offline')
const [typingByConversation, setTypingByConversation] = useState({})
const [showNewModal, setShowNewModal] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState([])
const [searching, setSearching] = useState(false)
const loadConversations = useCallback(async () => {
try {
const data = await apiFetch('/api/messages/conversations')
setConversations(data.data ?? [])
} catch (e) {
console.error('Failed to load conversations', e)
} finally {
setLoadingConvs(false)
}
}, [])
useEffect(() => {
loadConversations()
apiFetch('/api/messages/settings')
.then((data) => setRealtimeEnabled(!!data?.realtime_enabled))
.catch(() => setRealtimeEnabled(false))
}, [loadConversations])
useEffect(() => {
const handlePopState = () => {
const match = window.location.pathname.match(/^\/messages\/(\d+)$/)
setActiveId(match ? Number(match[1]) : null)
}
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)
}, [])
useEffect(() => {
if (realtimeEnabled) {
return undefined
}
const poll = window.setInterval(loadConversations, 15000)
return () => window.clearInterval(poll)
}, [loadConversations, realtimeEnabled])
useEffect(() => {
if (!realtimeEnabled || !userId) {
setRealtimeStatus('offline')
return undefined
}
const echo = getEcho()
if (!echo) {
setRealtimeStatus('offline')
return undefined
}
const connection = echo.connector?.pusher?.connection
let heartbeatId = null
const mapConnectionState = (state) => {
if (state === 'connected') {
return 'connected'
}
if (state === 'connecting' || state === 'initialized' || state === 'connecting_in') {
return 'connecting'
}
return 'offline'
}
const syncConnectionState = (payload = null) => {
const nextState = typeof payload?.current === 'string'
? payload.current
: connection?.state
if (echo.socketId?.()) {
setRealtimeStatus('connected')
return
}
setRealtimeStatus(mapConnectionState(nextState))
}
const handleVisibilitySync = () => {
if (document.visibilityState === 'visible') {
syncConnectionState()
}
}
syncConnectionState()
connection?.bind?.('state_change', syncConnectionState)
connection?.bind?.('connected', syncConnectionState)
connection?.bind?.('unavailable', syncConnectionState)
connection?.bind?.('disconnected', syncConnectionState)
heartbeatId = window.setInterval(syncConnectionState, 1000)
window.addEventListener('focus', syncConnectionState)
document.addEventListener('visibilitychange', handleVisibilitySync)
const channel = echo.private(`user.${userId}`)
const handleConversationUpdated = (payload) => {
const nextConversation = payload?.conversation
if (!nextConversation?.id) {
return
}
setConversations((prev) => mergeConversationSummary(prev, nextConversation))
}
channel.listen('.conversation.updated', handleConversationUpdated)
return () => {
connection?.unbind?.('state_change', syncConnectionState)
connection?.unbind?.('connected', syncConnectionState)
connection?.unbind?.('unavailable', syncConnectionState)
connection?.unbind?.('disconnected', syncConnectionState)
if (heartbeatId) {
window.clearInterval(heartbeatId)
}
window.removeEventListener('focus', syncConnectionState)
document.removeEventListener('visibilitychange', handleVisibilitySync)
channel.stopListening('.conversation.updated', handleConversationUpdated)
echo.leaveChannel(`private-user.${userId}`)
}
}, [realtimeEnabled, userId])
useEffect(() => {
if (!realtimeEnabled) {
setTypingByConversation({})
return undefined
}
const echo = getEcho()
if (!echo || conversations.length === 0) {
return undefined
}
const timers = new Map()
const joinedChannels = []
const removeTypingUser = (conversationId, userIdToRemove) => {
const timerKey = `${conversationId}:${userIdToRemove}`
const existingTimer = timers.get(timerKey)
if (existingTimer) {
window.clearTimeout(existingTimer)
timers.delete(timerKey)
}
setTypingByConversation((prev) => {
const current = prev[conversationId] ?? []
const nextUsers = current.filter((user) => String(user.user_id ?? user.id) !== String(userIdToRemove))
if (nextUsers.length === current.length) {
return prev
}
if (nextUsers.length === 0) {
const next = { ...prev }
delete next[conversationId]
return next
}
return {
...prev,
[conversationId]: nextUsers,
}
})
}
conversations.forEach((conversation) => {
if (!conversation?.id) {
return
}
const conversationId = conversation.id
const channel = echo.join(`conversation.${conversationId}`)
joinedChannels.push(conversationId)
channel
.listen('.typing.started', (payload) => {
const user = payload?.user
if (!user?.id || user.id === userId) {
return
}
setTypingByConversation((prev) => {
const current = prev[conversationId] ?? []
const index = current.findIndex((entry) => String(entry.user_id ?? entry.id) === String(user.id))
const nextUser = { user_id: user.id, username: user.username }
if (index === -1) {
return {
...prev,
[conversationId]: [...current, nextUser],
}
}
const nextUsers = [...current]
nextUsers[index] = { ...nextUsers[index], ...nextUser }
return {
...prev,
[conversationId]: nextUsers,
}
})
const timerKey = `${conversationId}:${user.id}`
const existingTimer = timers.get(timerKey)
if (existingTimer) {
window.clearTimeout(existingTimer)
}
const timeout = window.setTimeout(() => removeTypingUser(conversationId, user.id), Number(payload?.expires_in_ms ?? 3500))
timers.set(timerKey, timeout)
})
.listen('.typing.stopped', (payload) => {
const typingUserId = payload?.user?.id
if (!typingUserId) {
return
}
removeTypingUser(conversationId, typingUserId)
})
})
return () => {
timers.forEach((timer) => window.clearTimeout(timer))
joinedChannels.forEach((conversationId) => {
echo.leave(`conversation.${conversationId}`)
})
}
}, [conversations, realtimeEnabled, userId])
const handleSelectConversation = useCallback((id) => {
setActiveId(id)
history.replaceState(null, '', `/messages/${id}`)
}, [])
const handleConversationCreated = useCallback((conv) => {
setShowNewModal(false)
loadConversations()
setActiveId(conv.id)
history.replaceState(null, '', `/messages/${conv.id}`)
}, [loadConversations])
const handleMarkRead = useCallback((conversationId) => {
setConversations((prev) => prev.map((conversation) => (
conversation.id === conversationId
? { ...conversation, unread_count: 0 }
: conversation
)))
}, [])
const handleConversationPatched = useCallback((patch) => {
if (!patch?.id) {
return
}
setConversations((prev) => mergeConversationSummary(prev, patch))
}, [])
useEffect(() => {
let cancelled = false
const run = async () => {
const q = searchQuery.trim()
if (q.length < 2) {
setSearchResults([])
setSearching(false)
return
}
setSearching(true)
try {
const data = await apiFetch(`/api/messages/search?q=${encodeURIComponent(q)}`)
if (!cancelled) {
setSearchResults(data.data ?? [])
}
} catch {
if (!cancelled) {
setSearchResults([])
}
} finally {
if (!cancelled) {
setSearching(false)
}
}
}
const timer = setTimeout(run, 250)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [searchQuery])
const openSearchResult = useCallback((item) => {
if (!item?.conversation_id) return
setActiveId(item.conversation_id)
history.replaceState(null, '', `/messages/${item.conversation_id}?focus=${item.id}`)
}, [])
const activeConversation = conversations.find((conversation) => conversation.id === activeId) ?? null
const unreadCount = conversations.reduce((sum, conversation) => sum + Number(conversation.unread_count || 0), 0)
const pinnedCount = conversations.reduce((sum, conversation) => {
const me = conversation.my_participant ?? conversation.all_participants?.find((participant) => participant.user_id === userId)
return sum + (me?.is_pinned ? 1 : 0)
}, 0)
const archivedCount = conversations.reduce((sum, conversation) => {
const me = conversation.my_participant ?? conversation.all_participants?.find((participant) => participant.user_id === userId)
return sum + (me?.is_archived ? 1 : 0)
}, 0)
const activeSearch = searchQuery.trim().length >= 2
const activeConversationLabel = activeConversation?.title
|| activeConversation?.all_participants?.find((participant) => participant.user_id !== userId)?.user?.username
|| 'Conversation'
return (
<div className="messages-page px-4 pb-16 pt-4 md:px-6 lg:px-8 lg:pt-6">
<div className="grid gap-5 lg:items-start lg:grid-cols-[340px_minmax(0,1fr)] xl:grid-cols-[360px_minmax(0,1fr)] xl:gap-6">
<aside className={`flex min-h-[calc(100vh-18rem)] flex-col overflow-hidden rounded-[30px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(10,16,26,0.96),rgba(7,11,18,0.92))] shadow-[0_20px_60px_rgba(0,0,0,0.28)] lg:sticky lg:top-6 lg:max-h-[calc(100vh-3rem)] ${activeId ? 'hidden lg:flex' : 'flex'}`}>
<div className="border-b border-white/[0.06] p-5">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/35">Private inbox</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Messages</h2>
<p className="mt-2 text-sm leading-6 text-white/50">Keep direct chats, group threads, and file drops in one focused workspace.</p>
</div>
<button
onClick={() => setShowNewModal(true)}
className="inline-flex h-11 items-center justify-center gap-2 rounded-full border border-sky-400/20 bg-sky-500/12 px-4 text-sm font-medium text-sky-200 transition hover:bg-sky-500/18"
title="New message"
>
<i className="fa-solid fa-pen-to-square text-xs" />
Compose
</button>
</div>
<div className="mt-5 grid grid-cols-3 gap-2">
<StatChip label="Unread" value={unreadCount} tone="sky" />
<StatChip label="Pinned" value={pinnedCount} tone="amber" />
<StatChip label="Archived" value={archivedCount} tone="slate" />
</div>
<div className="mt-4 flex flex-wrap gap-2 text-xs text-white/45">
<span className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 ${connectionBadgeClass(realtimeEnabled, realtimeStatus)}`}>
<span className={`h-1.5 w-1.5 rounded-full ${connectionDotClass(realtimeEnabled, realtimeStatus)}`} />
{connectionBadgeLabel(realtimeEnabled, realtimeStatus)}
</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-white/55">
<i className="fa-solid fa-comments text-[10px]" />
{conversations.length} conversations
</span>
</div>
</div>
<div className="border-b border-white/[0.06] p-4">
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.18em] text-white/35">Search all messages</label>
<div className="rounded-2xl border border-white/[0.08] bg-black/15 px-3 py-2.5 transition focus-within:border-sky-400/30 focus-within:bg-black/25">
<div className="flex items-center gap-3">
<i className="fa-solid fa-magnifying-glass text-xs text-white/30" />
<input
type="search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Find text, attachments, or senders…"
className="w-full bg-transparent text-sm text-white outline-none placeholder:text-white/25"
/>
</div>
</div>
{searching ? <p className="mt-2 text-[11px] text-white/35">Searching across your inbox</p> : null}
</div>
{activeSearch ? (
<div className="border-b border-white/[0.06] px-3 py-3">
<div className="mb-2 flex items-center justify-between px-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/35">Search results</p>
<span className="text-[11px] text-white/30">{searchResults.length}</span>
</div>
<div className="max-h-64 space-y-2 overflow-y-auto pr-1">
{searchResults.length === 0 && !searching ? (
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.025] px-4 py-4 text-sm text-white/40">
No results matched {searchQuery.trim()}.
</div>
) : null}
{searchResults.map((item) => (
<button
key={`search-${item.id}`}
onClick={() => openSearchResult(item)}
className="block w-full rounded-2xl border border-white/[0.06] bg-white/[0.03] px-4 py-3 text-left transition hover:border-sky-400/20 hover:bg-sky-500/[0.08]"
>
<div className="flex items-center justify-between gap-3 text-[11px] text-white/35">
<span>@{item.sender?.username ?? 'unknown'}</span>
<span>{relativeTime(item.created_at)}</span>
</div>
<p className="mt-2 line-clamp-2 text-sm leading-6 text-white/78">{buildSearchPreview(item)}</p>
</button>
))}
</div>
</div>
) : null}
<ConversationList
conversations={conversations}
loading={loadingConvs}
activeId={activeId}
currentUserId={userId}
typingByConversation={typingByConversation}
onSelect={handleSelectConversation}
/>
</aside>
<main className={`flex min-h-[calc(100vh-18rem)] min-w-0 flex-col overflow-hidden rounded-[30px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(10,16,26,0.96),rgba(7,11,18,0.92))] shadow-[0_20px_60px_rgba(0,0,0,0.28)] lg:h-[calc(100vh-3rem)] lg:max-h-[calc(100vh-3rem)] ${activeId ? 'flex' : 'hidden lg:flex'}`}>
{activeId ? (
<ConversationThread
key={activeId}
conversationId={activeId}
conversation={activeConversation}
realtimeEnabled={realtimeEnabled}
realtimeStatus={realtimeStatus}
currentUserId={userId}
currentUsername={username}
apiFetch={apiFetch}
onBack={() => {
setActiveId(null)
history.replaceState(null, '', '/messages')
}}
onMarkRead={handleMarkRead}
onConversationPatched={handleConversationPatched}
/>
) : (
<div className="flex flex-1 items-center justify-center p-8">
<div className="max-w-xl text-center">
<div className="mx-auto flex h-18 w-18 items-center justify-center rounded-[26px] border border-white/[0.08] bg-white/[0.03] text-white/35 shadow-[0_18px_45px_rgba(0,0,0,0.22)]">
<i className="fa-solid fa-comments text-3xl" />
</div>
<h2 className="mt-6 text-3xl font-semibold text-white">Choose a conversation</h2>
<p className="mt-3 text-sm leading-7 text-white/55">Jump back into a direct message, catch up on a group thread, or start a new conversation with creators and collaborators.</p>
<div className="mt-6 flex flex-wrap items-center justify-center gap-2 text-sm text-white/55">
<span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2">Search your full message history</span>
<span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2">Share files inline</span>
<span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2">Track seen status</span>
</div>
</div>
</div>
)}
</main>
</div>
{activeId ? (
<div className="mt-4 rounded-2xl border border-white/[0.06] bg-white/[0.03] px-4 py-3 text-xs text-white/45 lg:hidden">
Viewing <span className="font-medium text-white/75">{activeConversationLabel}</span>. Use the back button to return to your inbox list.
</div>
) : null}
{showNewModal ? (
<NewConversationModal
currentUserId={userId}
apiFetch={apiFetch}
onCreated={handleConversationCreated}
onClose={() => setShowNewModal(false)}
/>
) : null}
</div>
)
}
function StatChip({ label, value, tone = 'sky' }) {
const tones = {
sky: 'border-sky-400/20 bg-sky-500/10 text-sky-200',
amber: 'border-amber-400/20 bg-amber-500/10 text-amber-200',
slate: 'border-white/[0.08] bg-white/[0.04] text-white/65',
}
return (
<div className={`rounded-2xl border px-3 py-3 ${tones[tone] || tones.sky}`}>
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] opacity-70">{label}</p>
<p className="mt-2 text-lg font-semibold">{Number(value || 0).toLocaleString()}</p>
</div>
)
}
function mergeConversationSummary(existing, incoming) {
const next = [...existing]
const index = next.findIndex((conversation) => conversation.id === incoming.id)
if (index >= 0) {
next[index] = { ...next[index], ...incoming }
} else {
next.unshift(incoming)
}
return next.sort((left, right) => {
const leftPinned = left.my_participant?.is_pinned ? 1 : 0
const rightPinned = right.my_participant?.is_pinned ? 1 : 0
if (leftPinned !== rightPinned) {
return rightPinned - leftPinned
}
const leftPinnedAt = left.my_participant?.pinned_at ? new Date(left.my_participant.pinned_at).getTime() : 0
const rightPinnedAt = right.my_participant?.pinned_at ? new Date(right.my_participant.pinned_at).getTime() : 0
if (leftPinnedAt !== rightPinnedAt) {
return rightPinnedAt - leftPinnedAt
}
const leftTime = left.last_message_at ? new Date(left.last_message_at).getTime() : 0
const rightTime = right.last_message_at ? new Date(right.last_message_at).getTime() : 0
return rightTime - leftTime
})
}
function connectionBadgeClass(realtimeEnabled, realtimeStatus) {
if (!realtimeEnabled) {
return 'border-white/[0.08] bg-white/[0.04] text-white/55'
}
if (realtimeStatus === 'connected') {
return 'border-emerald-400/20 bg-emerald-500/10 text-emerald-200'
}
if (realtimeStatus === 'connecting') {
return 'border-amber-400/20 bg-amber-500/10 text-amber-200'
}
return 'border-rose-400/18 bg-rose-500/10 text-rose-200'
}
function connectionDotClass(realtimeEnabled, realtimeStatus) {
if (!realtimeEnabled) {
return 'bg-white/30'
}
if (realtimeStatus === 'connected') {
return 'bg-emerald-300'
}
if (realtimeStatus === 'connecting') {
return 'bg-amber-300'
}
return 'bg-rose-300'
}
function connectionBadgeLabel(realtimeEnabled, realtimeStatus) {
if (!realtimeEnabled) {
return 'Polling every 15s'
}
if (realtimeStatus === 'connected') {
return 'Realtime connected'
}
if (realtimeStatus === 'connecting') {
return 'Realtime connecting'
}
return 'Realtime disconnected'
}
const el = document.getElementById('messages-root')
if (el) {
function parse(key, fallback = null) {
try {
return JSON.parse(el.dataset[key] ?? 'null') ?? fallback
} catch {
return fallback
}
}
createRoot(el).render(
<MessagesPage
userId={parse('userId')}
username={parse('username', '')}
activeConversationId={parse('activeConversationId')}
/>,
)
}
export default MessagesPage