frontend: now working fully, fuctionality tested
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Sidebar from '../components/Sidebar'
|
||||
import ChatWindow from '../components/ChatWindow'
|
||||
import NewChatDialog from '../components/NewChatDialog'
|
||||
import { api } from '../lib/api'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { useConversations } from '../hooks/useConversations'
|
||||
import { useMessages } from '../hooks/useMessages'
|
||||
import { useRealtime } from '../hooks/useRealtime'
|
||||
import type { ClientMessage, Message, User, WsEvent } from '../lib/types'
|
||||
|
||||
interface ChatAppProps {
|
||||
user: User
|
||||
}
|
||||
|
||||
export default function ChatApp({ user }: ChatAppProps) {
|
||||
const { logout } = useAuth()
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
const [directory, setDirectory] = useState<Map<number, User>>(() => new Map([[user.id, user]]))
|
||||
const [learnedPeers, setLearnedPeers] = useState<Record<number, number>>({})
|
||||
const [presence, setPresence] = useState<Record<number, boolean>>({})
|
||||
const [typing, setTyping] = useState<Record<number, boolean>>({})
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [focusTick, setFocusTick] = useState(0)
|
||||
|
||||
const { conversations, loading, error, reload, add, patch } = useConversations()
|
||||
const {
|
||||
messages,
|
||||
loading: messagesLoading,
|
||||
loadingOlder,
|
||||
hasMore,
|
||||
error: messagesError,
|
||||
loadOlder,
|
||||
upsert,
|
||||
addOptimistic,
|
||||
markSending,
|
||||
failOptimistic,
|
||||
markReadUpTo,
|
||||
} = useMessages(selectedId, user.id)
|
||||
|
||||
const selectedIdRef = useRef(selectedId)
|
||||
const conversationsRef = useRef(conversations)
|
||||
const typingTimers = useRef(new Map<number, number>())
|
||||
|
||||
useEffect(() => {
|
||||
selectedIdRef.current = selectedId
|
||||
}, [selectedId])
|
||||
|
||||
useEffect(() => {
|
||||
conversationsRef.current = conversations
|
||||
}, [conversations])
|
||||
|
||||
const rememberUser = useCallback((next: User) => {
|
||||
setDirectory((prev) => {
|
||||
const existing = prev.get(next.id)
|
||||
if (
|
||||
existing &&
|
||||
existing.display_name === next.display_name &&
|
||||
existing.username === next.username &&
|
||||
existing.avatar === next.avatar
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
const map = new Map(prev)
|
||||
map.set(next.id, next)
|
||||
return map
|
||||
})
|
||||
}, [])
|
||||
|
||||
const loadContacts = useCallback(async () => {
|
||||
const contacts = await api.listContacts().catch(() => [])
|
||||
contacts.forEach((contact) => rememberUser(contact.user))
|
||||
}, [rememberUser])
|
||||
|
||||
useEffect(() => {
|
||||
void loadContacts()
|
||||
}, [loadContacts])
|
||||
|
||||
useEffect(() => {
|
||||
const onFocus = () => setFocusTick((tick) => tick + 1)
|
||||
window.addEventListener('focus', onFocus)
|
||||
return () => window.removeEventListener('focus', onFocus)
|
||||
}, [])
|
||||
|
||||
const peers = useMemo(() => {
|
||||
const map: Record<number, number> = { ...learnedPeers }
|
||||
for (const conversation of conversations) {
|
||||
if (map[conversation.id] !== undefined) continue
|
||||
const last = conversation.last_message
|
||||
if (last && last.sender_id !== user.id) map[conversation.id] = last.sender_id
|
||||
}
|
||||
return map
|
||||
}, [learnedPeers, conversations, user.id])
|
||||
|
||||
const handleEvent = useCallback(
|
||||
(event: WsEvent) => {
|
||||
switch (event.type) {
|
||||
case 'message.created': {
|
||||
const { temp_id, conversation_id, message_id, sender_id, body, created_at } = event.data
|
||||
const message: Message = { id: message_id, conversation_id, sender_id, body, created_at }
|
||||
if (conversation_id === selectedIdRef.current || sender_id === user.id) {
|
||||
upsert(message, temp_id)
|
||||
}
|
||||
if (!conversationsRef.current.some((conversation) => conversation.id === conversation_id)) {
|
||||
void reload()
|
||||
return
|
||||
}
|
||||
patch(conversation_id, (conversation) => {
|
||||
const mine = sender_id === user.id
|
||||
const open = conversation_id === selectedIdRef.current
|
||||
const unread_count = mine || open ? 0 : (conversation.unread_count ?? 0) + 1
|
||||
return { ...conversation, last_message: message, unread_count }
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'typing': {
|
||||
const { conversation_id, user_id, is_typing } = event.data
|
||||
if (user_id === user.id) break
|
||||
const timers = typingTimers.current
|
||||
const existing = timers.get(conversation_id)
|
||||
if (existing !== undefined) {
|
||||
window.clearTimeout(existing)
|
||||
timers.delete(conversation_id)
|
||||
}
|
||||
if (is_typing) {
|
||||
const timer = window.setTimeout(() => {
|
||||
timers.delete(conversation_id)
|
||||
setTyping((prev) => (prev[conversation_id] ? { ...prev, [conversation_id]: false } : prev))
|
||||
}, 3000)
|
||||
timers.set(conversation_id, timer)
|
||||
}
|
||||
setTyping((prev) =>
|
||||
prev[conversation_id] === is_typing ? prev : { ...prev, [conversation_id]: is_typing },
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'presence': {
|
||||
const { user_id, status } = event.data
|
||||
const online = status === 'online'
|
||||
setPresence((prev) => (prev[user_id] === online ? prev : { ...prev, [user_id]: online }))
|
||||
break
|
||||
}
|
||||
case 'read': {
|
||||
const { conversation_id, user_id, message_id } = event.data
|
||||
if (user_id === user.id) break
|
||||
if (conversation_id === selectedIdRef.current) markReadUpTo(message_id)
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
[markReadUpTo, patch, reload, upsert, user.id],
|
||||
)
|
||||
|
||||
const realtime = useRealtime(handleEvent)
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (conversationId: number, body: string) => {
|
||||
const text = body.trim()
|
||||
if (!text) return
|
||||
const tempId = crypto.randomUUID()
|
||||
const optimistic: ClientMessage = {
|
||||
id: -Date.now(),
|
||||
conversation_id: conversationId,
|
||||
sender_id: user.id,
|
||||
body: text,
|
||||
created_at: new Date().toISOString(),
|
||||
temp_id: tempId,
|
||||
status: 'sending',
|
||||
}
|
||||
addOptimistic(optimistic)
|
||||
patch(conversationId, (conversation) => ({ ...conversation, last_message: optimistic, unread_count: 0 }))
|
||||
try {
|
||||
const result = await api.sendMessage(conversationId, text, tempId)
|
||||
upsert(result.message, tempId)
|
||||
patch(conversationId, (conversation) => ({
|
||||
...conversation,
|
||||
last_message: result.message,
|
||||
unread_count: 0,
|
||||
}))
|
||||
} catch {
|
||||
failOptimistic(tempId)
|
||||
}
|
||||
},
|
||||
[addOptimistic, failOptimistic, patch, upsert, user.id],
|
||||
)
|
||||
|
||||
const resendMessage = useCallback(
|
||||
async (message: ClientMessage) => {
|
||||
const tempId = message.temp_id
|
||||
if (!tempId) return
|
||||
markSending(tempId)
|
||||
try {
|
||||
const result = await api.sendMessage(message.conversation_id, message.body, tempId)
|
||||
upsert(result.message, tempId)
|
||||
patch(message.conversation_id, (conversation) => ({
|
||||
...conversation,
|
||||
last_message: result.message,
|
||||
unread_count: 0,
|
||||
}))
|
||||
} catch {
|
||||
failOptimistic(tempId)
|
||||
}
|
||||
},
|
||||
[failOptimistic, markSending, patch, upsert],
|
||||
)
|
||||
|
||||
const sendTyping = useCallback(
|
||||
(conversationId: number, isTyping: boolean) => {
|
||||
realtime.sendTyping(conversationId, isTyping)
|
||||
},
|
||||
[realtime],
|
||||
)
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: number) => {
|
||||
setSelectedId(id)
|
||||
patch(id, (conversation) =>
|
||||
conversation.unread_count ? { ...conversation, unread_count: 0 } : conversation,
|
||||
)
|
||||
},
|
||||
[patch],
|
||||
)
|
||||
|
||||
const handleStartChat = useCallback(
|
||||
async (target: User) => {
|
||||
const conversation = await api.createDM(target.id)
|
||||
rememberUser(target)
|
||||
setLearnedPeers((prev) => ({ ...prev, [conversation.id]: target.id }))
|
||||
add(conversation)
|
||||
setSelectedId(conversation.id)
|
||||
void loadContacts()
|
||||
},
|
||||
[add, loadContacts, rememberUser],
|
||||
)
|
||||
|
||||
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null
|
||||
const lastMessageId = lastMessage && lastMessage.id > 0 ? lastMessage.id : null
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId === null || lastMessageId === null) return
|
||||
if (document.hasFocus()) realtime.sendRead(selectedId, lastMessageId)
|
||||
}, [selectedId, lastMessageId, realtime, focusTick])
|
||||
|
||||
const peerIsTyping = selectedId !== null && typing[selectedId] === true
|
||||
|
||||
return (
|
||||
<div className="h-full p-0 md:p-5">
|
||||
<div className="mx-auto flex h-full max-w-[1440px] overflow-hidden border-line bg-panel/70 backdrop-blur-xl md:rounded-[28px] md:border md:shadow-2xl md:shadow-black/40">
|
||||
<Sidebar
|
||||
className={selectedId !== null ? 'hidden md:flex' : 'flex'}
|
||||
conversations={conversations}
|
||||
loading={loading}
|
||||
error={error}
|
||||
selectedId={selectedId}
|
||||
currentUser={user}
|
||||
directory={directory}
|
||||
peers={peers}
|
||||
presence={presence}
|
||||
typing={typing}
|
||||
onSelect={handleSelect}
|
||||
onNewChat={() => setDialogOpen(true)}
|
||||
onRetry={() => void reload()}
|
||||
onLogout={() => void logout()}
|
||||
/>
|
||||
|
||||
<ChatWindow
|
||||
className={selectedId === null ? 'hidden md:flex' : 'flex'}
|
||||
conversation={conversations.find((conversation) => conversation.id === selectedId) ?? null}
|
||||
currentUser={user}
|
||||
directory={directory}
|
||||
peers={peers}
|
||||
presence={presence}
|
||||
peerIsTyping={peerIsTyping}
|
||||
messages={messages}
|
||||
loading={messagesLoading}
|
||||
loadingOlder={loadingOlder}
|
||||
hasMore={hasMore}
|
||||
error={messagesError}
|
||||
onBack={() => setSelectedId(null)}
|
||||
onSend={sendMessage}
|
||||
onTyping={sendTyping}
|
||||
onLoadOlder={() => void loadOlder()}
|
||||
onRetry={resendMessage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{dialogOpen && (
|
||||
<NewChatDialog
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onStartChat={handleStartChat}
|
||||
onContactsChanged={() => void loadContacts()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user