2026-09-27 20:12:29 +03:00
|
|
|
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'
|
2026-09-27 21:59:48 +03:00
|
|
|
import type { ClientMessage, Media, Member, Message, User, WsEvent } from '../lib/types'
|
2026-09-27 20:12:29 +03:00
|
|
|
|
|
|
|
|
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)
|
2026-09-27 21:59:48 +03:00
|
|
|
const [membersVersion, setMembersVersion] = useState(0)
|
2026-09-27 20:12:29 +03:00
|
|
|
|
2026-09-27 21:59:48 +03:00
|
|
|
const { conversations, loading, error, reload, add, remove, patch } = useConversations()
|
2026-09-27 20:12:29 +03:00
|
|
|
const {
|
|
|
|
|
messages,
|
|
|
|
|
loading: messagesLoading,
|
|
|
|
|
loadingOlder,
|
|
|
|
|
hasMore,
|
|
|
|
|
error: messagesError,
|
|
|
|
|
loadOlder,
|
|
|
|
|
upsert,
|
2026-09-27 21:59:48 +03:00
|
|
|
patchMessage,
|
2026-09-27 20:12:29 +03:00
|
|
|
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': {
|
2026-09-27 21:59:48 +03:00
|
|
|
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,
|
|
|
|
|
}
|
2026-09-27 20:12:29 +03:00
|
|
|
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
|
|
|
|
|
}
|
2026-09-27 21:59:48 +03:00
|
|
|
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
|
|
|
|
|
}
|
2026-09-27 20:12:29 +03:00
|
|
|
}
|
|
|
|
|
},
|
2026-09-27 21:59:48 +03:00
|
|
|
[markReadUpTo, patch, patchMessage, reload, remove, upsert, user.id],
|
2026-09-27 20:12:29 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const realtime = useRealtime(handleEvent)
|
|
|
|
|
|
|
|
|
|
const sendMessage = useCallback(
|
2026-09-27 21:59:48 +03:00
|
|
|
async (conversationId: number, body: string, media: Media | null) => {
|
2026-09-27 20:12:29 +03:00
|
|
|
const text = body.trim()
|
2026-09-27 21:59:48 +03:00
|
|
|
if (!text && !media) return
|
2026-09-27 20:12:29 +03:00
|
|
|
const tempId = crypto.randomUUID()
|
|
|
|
|
const optimistic: ClientMessage = {
|
|
|
|
|
id: -Date.now(),
|
|
|
|
|
conversation_id: conversationId,
|
|
|
|
|
sender_id: user.id,
|
|
|
|
|
body: text,
|
2026-09-27 21:59:48 +03:00
|
|
|
reactions: [],
|
|
|
|
|
media,
|
2026-09-27 20:12:29 +03:00
|
|
|
created_at: new Date().toISOString(),
|
|
|
|
|
temp_id: tempId,
|
|
|
|
|
status: 'sending',
|
|
|
|
|
}
|
|
|
|
|
addOptimistic(optimistic)
|
|
|
|
|
patch(conversationId, (conversation) => ({ ...conversation, last_message: optimistic, unread_count: 0 }))
|
|
|
|
|
try {
|
2026-09-27 21:59:48 +03:00
|
|
|
const result = await api.sendMessage(conversationId, text, tempId, media?.id)
|
2026-09-27 20:12:29 +03:00
|
|
|
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 {
|
2026-09-27 21:59:48 +03:00
|
|
|
const result = await api.sendMessage(message.conversation_id, message.body, tempId, message.media?.id)
|
2026-09-27 20:12:29 +03:00
|
|
|
upsert(result.message, tempId)
|
|
|
|
|
patch(message.conversation_id, (conversation) => ({
|
|
|
|
|
...conversation,
|
|
|
|
|
last_message: result.message,
|
|
|
|
|
unread_count: 0,
|
|
|
|
|
}))
|
|
|
|
|
} catch {
|
|
|
|
|
failOptimistic(tempId)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[failOptimistic, markSending, patch, upsert],
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-27 21:59:48 +03:00
|
|
|
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)
|
2026-09-27 22:12:45 +03:00
|
|
|
patchMessage(message.id, (current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
...updated,
|
|
|
|
|
media: updated.media ?? current.media,
|
|
|
|
|
reactions: updated.reactions,
|
|
|
|
|
}))
|
2026-09-27 21:59:48 +03:00
|
|
|
} catch {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-09-27 22:12:45 +03:00
|
|
|
[patchMessage, user.id],
|
2026-09-27 21:59:48 +03:00
|
|
|
)
|
|
|
|
|
|
2026-09-27 20:12:29 +03:00
|
|
|
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],
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-27 21:59:48 +03:00
|
|
|
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(
|
|
|
|
|
(members: Member[]) => {
|
|
|
|
|
members.forEach((member) => rememberUser(member.user))
|
|
|
|
|
},
|
|
|
|
|
[rememberUser],
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-27 20:12:29 +03:00
|
|
|
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
|
2026-09-27 21:59:48 +03:00
|
|
|
key={selectedId ?? 'empty'}
|
2026-09-27 20:12:29 +03:00
|
|
|
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}
|
2026-09-27 21:59:48 +03:00
|
|
|
membersVersion={membersVersion}
|
2026-09-27 20:12:29 +03:00
|
|
|
onBack={() => setSelectedId(null)}
|
|
|
|
|
onSend={sendMessage}
|
|
|
|
|
onTyping={sendTyping}
|
|
|
|
|
onLoadOlder={() => void loadOlder()}
|
|
|
|
|
onRetry={resendMessage}
|
2026-09-27 21:59:48 +03:00
|
|
|
onReact={(message, emoji) => void handleReaction(message, emoji)}
|
|
|
|
|
onMembersChanged={() => setMembersVersion((version) => version + 1)}
|
|
|
|
|
onMembersLoaded={handleMembersLoaded}
|
2026-09-27 20:12:29 +03:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{dialogOpen && (
|
|
|
|
|
<NewChatDialog
|
|
|
|
|
onClose={() => setDialogOpen(false)}
|
|
|
|
|
onStartChat={handleStartChat}
|
2026-09-27 21:59:48 +03:00
|
|
|
onCreateGroup={handleCreateGroup}
|
2026-09-27 20:12:29 +03:00
|
|
|
onContactsChanged={() => void loadContacts()}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|