486 lines
16 KiB
TypeScript
486 lines
16 KiB
TypeScript
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>
|
||
)
|
||
}
|