frontend: now working fully, fuctionality tested
CI / Go (build + vet + test) (push) Canceled after 0s
CI / Web (build + lint) (push) Canceled after 0s

This commit is contained in:
vantablackTea
2026-09-27 20:12:29 +03:00
parent 1f3a0e4351
commit ca0aa9ee9c
15 changed files with 1657 additions and 74 deletions
+296
View File
@@ -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>
)
}