Files
Messenger/web/src/screens/ChatApp.tsx
T
vantablackTea a0b17d25bf
CI / Go (build + vet + test) (push) Canceled after 0s
CI / Web (build + lint) (push) Canceled after 0s
fix: WS upgrade via metrics middleware (realtime), DM names, tap reactions
2026-09-28 14:07:59 +03:00

486 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Sidebar from '../components/Sidebar'
import ChatWindow from '../components/ChatWindow'
import NewChatDialog from '../components/NewChatDialog'
import SearchDialog from '../components/SearchDialog'
import { api } from '../lib/api'
import { ensurePushSubscription } from '../lib/push'
import { useAuth } from '../auth/AuthContext'
import { useConversations } from '../hooks/useConversations'
import { useMessages } from '../hooks/useMessages'
import { useRealtime } from '../hooks/useRealtime'
import type { ClientMessage, Media, Member, 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 [searchOpen, setSearchOpen] = useState(false)
const [focusTick, setFocusTick] = useState(0)
const [membersVersion, setMembersVersion] = useState(0)
const { conversations, loading, error, reload, add, remove, patch } = useConversations()
const {
messages,
loading: messagesLoading,
loadingOlder,
hasMore,
error: messagesError,
loadOlder,
upsert,
patchMessage,
addOptimistic,
markSending,
failOptimistic,
markReadUpTo,
} = useMessages(selectedId, user.id)
const selectedIdRef = useRef(selectedId)
const conversationsRef = useRef(conversations)
const typingTimers = useRef(new Map<number, number>())
const membersLoadedRef = useRef(new Set<number>())
const membersInFlightRef = useRef(new Set<number>())
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
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(() => {
for (const conversation of conversations) {
if (membersLoadedRef.current.has(conversation.id) || membersInFlightRef.current.has(conversation.id)) {
continue
}
membersInFlightRef.current.add(conversation.id)
api
.listMembers(conversation.id)
.then((members) => {
membersLoadedRef.current.add(conversation.id)
if (!mountedRef.current) return
members.forEach((member) => rememberUser(member.user))
if (conversation.type !== 'group') {
const others = members.filter((member) => member.user.id !== user.id)
if (others.length === 1) {
const peerId = others[0].user.id
setLearnedPeers((prev) =>
prev[conversation.id] === peerId ? prev : { ...prev, [conversation.id]: peerId },
)
}
}
})
.catch(() => {})
.finally(() => {
membersInFlightRef.current.delete(conversation.id)
})
}
}, [conversations, rememberUser, user.id])
useEffect(() => {
void ensurePushSubscription()
}, [])
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, reactions, media } = event.data
const message: Message = {
id: message_id,
conversation_id,
sender_id,
body,
created_at,
reactions: reactions ?? [],
media: media ?? null,
}
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
}
case 'reaction.added': {
const { conversation_id, message_id, user_id, emoji } = event.data
if (conversation_id !== selectedIdRef.current) break
patchMessage(message_id, (message) => {
if (message.reactions.some((reaction) => reaction.emoji === emoji && reaction.user_id === user_id)) {
return message
}
return { ...message, reactions: [...message.reactions, { emoji, user_id }] }
})
break
}
case 'reaction.removed': {
const { conversation_id, message_id, user_id, emoji } = event.data
if (conversation_id !== selectedIdRef.current) break
patchMessage(message_id, (message) => ({
...message,
reactions: message.reactions.filter(
(reaction) => !(reaction.emoji === emoji && reaction.user_id === user_id),
),
}))
break
}
case 'member.added':
case 'member.removed': {
if (event.data.conversation_id === selectedIdRef.current) {
setMembersVersion((version) => version + 1)
}
break
}
case 'conversation.added': {
void reload()
break
}
case 'conversation.removed': {
const { conversation_id } = event.data
remove(conversation_id)
if (selectedIdRef.current === conversation_id) setSelectedId(null)
break
}
}
},
[markReadUpTo, patch, patchMessage, reload, remove, upsert, user.id],
)
const realtime = useRealtime(handleEvent)
const sendMessage = useCallback(
async (conversationId: number, body: string, media: Media | null) => {
const text = body.trim()
if (!text && !media) return
const tempId = crypto.randomUUID()
const optimistic: ClientMessage = {
id: -Date.now(),
conversation_id: conversationId,
sender_id: user.id,
body: text,
reactions: [],
media,
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, media?.id)
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, message.media?.id)
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 handleReaction = useCallback(
async (message: ClientMessage, emoji: string) => {
if (message.id <= 0) return
const mine = message.reactions.some((reaction) => reaction.emoji === emoji && reaction.user_id === user.id)
try {
const updated = mine
? await api.removeReaction(message.conversation_id, message.id, emoji)
: await api.addReaction(message.conversation_id, message.id, emoji)
patchMessage(message.id, (current) => ({
...current,
...updated,
media: updated.media ?? current.media,
reactions: updated.reactions,
}))
} catch {
return
}
},
[patchMessage, user.id],
)
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 handleCreateGroup = useCallback(
async (name: string, memberIds: number[]) => {
const conversation = await api.createGroup(name, memberIds)
add(conversation)
setSelectedId(conversation.id)
setMembersVersion((version) => version + 1)
},
[add],
)
const handleMembersLoaded = useCallback(
(conversationId: number, members: Member[]) => {
membersLoadedRef.current.add(conversationId)
members.forEach((member) => rememberUser(member.user))
const conversation = conversationsRef.current.find((item) => item.id === conversationId)
if (conversation && conversation.type !== 'group') {
const others = members.filter((member) => member.user.id !== user.id)
if (others.length === 1) {
const peerId = others[0].user.id
setLearnedPeers((prev) =>
prev[conversationId] === peerId ? prev : { ...prev, [conversationId]: peerId },
)
}
}
},
[rememberUser, user.id],
)
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="flex h-full flex-col p-0 md:p-4">
<div className="bevel mx-auto flex h-full w-full max-w-[1440px] flex-col bg-panel md:shadow-[4px_4px_0_rgba(0,0,0,0.35)]">
<div className="win-title flex shrink-0 items-center gap-1.5 px-1.5 py-1">
<span className="flex h-4 w-4 items-center justify-center bg-white text-accent">
<svg viewBox="0 0 24 24" className="h-3 w-3" fill="none" aria-hidden="true">
<path
d="M4 12.5h3l2-5 3.2 9 2.3-6 1.5 2H20"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="square"
strokeLinejoin="miter"
/>
</svg>
</span>
<span className="flex-1 truncate text-xs font-bold tracking-wide">Vibe — Мессенджер</span>
<span className="win-btn" aria-hidden="true">
_
</span>
<span className="win-btn" aria-hidden="true">
□
</span>
<span className="win-btn" aria-hidden="true">
×
</span>
</div>
<div className="flex min-h-0 flex-1">
<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)}
onSearch={() => setSearchOpen(true)}
onRetry={() => void reload()}
onLogout={() => void logout()}
/>
<ChatWindow
key={selectedId ?? 'empty'}
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}
membersVersion={membersVersion}
onBack={() => setSelectedId(null)}
onSend={sendMessage}
onTyping={sendTyping}
onLoadOlder={() => void loadOlder()}
onRetry={resendMessage}
onReact={(message, emoji) => void handleReaction(message, emoji)}
onMembersChanged={() => setMembersVersion((version) => version + 1)}
onMembersLoaded={handleMembersLoaded}
/>
</div>
</div>
{dialogOpen && (
<NewChatDialog
onClose={() => setDialogOpen(false)}
onStartChat={handleStartChat}
onCreateGroup={handleCreateGroup}
onContactsChanged={() => void loadContacts()}
/>
)}
{searchOpen && (
<SearchDialog
onClose={() => setSearchOpen(false)}
onSelect={handleSelect}
conversations={conversations}
peers={peers}
directory={directory}
currentUser={user}
/>
)}
</div>
)
}