297 lines
9.8 KiB
TypeScript
297 lines
9.8 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 { 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>
|
|
)
|
|
}
|