import { useCallback, useEffect, useRef, useState } from "react"; import { useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { FiMoreVertical } from "react-icons/fi"; import type { Message as MessageModel } from "@/api/generated/private/models/message"; import { getApiSocialChatsListQueryKey, useApiSocialChatsRetrieve } from "@/api/generated/private/chat/chat"; import { useAuth } from "@/hooks/useAuth"; import { useInfiniteMessages } from "@/hooks/useInfiniteMessages"; import { useChatSocket, type ChatSocketEvent } from "@/hooks/useChatSocket"; import { useIntersectionLoader } from "@/hooks/useIntersectionLoader"; import { messagesQueryKey, type CursorPaginated } from "@/api/social/feed"; import { sendChatMessage } from "@/api/social/chatSend"; import Message from "@/components/social/chat/Message"; import MessageComposer from "@/components/social/chat/MessageComposer"; import ChatSettingsModal from "@/components/social/chat/ChatSettingsModal"; import Spinner from "@/components/ui/Spinner"; import EmptyState from "@/components/ui/EmptyState"; import Avatar from "@/components/ui/Avatar"; export default function ChatRoomPage() { const { t } = useTranslation("social"); const { chatId: chatIdParam } = useParams<{ chatId: string }>(); const chatId = Number(chatIdParam); const queryClient = useQueryClient(); const { user } = useAuth(); const { data: chat } = useApiSocialChatsRetrieve(String(chatId)); const { messages, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteMessages({ chatId }); const [replyTo, setReplyTo] = useState(null); const [typingUsers, setTypingUsers] = useState([]); const [settingsOpen, setSettingsOpen] = useState(false); const bottomRef = useRef(null); // --- reply-jump logic --- const [highlightedId, setHighlightedId] = useState(null); /** Message id we're waiting to appear in the DOM while fetching older pages. */ const [pendingScrollId, setPendingScrollId] = useState(null); /** IDs removed by a delete WS event — so we don't hunt for them in older pages. */ const deletedMessageIds = useRef(new Set()); function flashMessage(id: number) { setHighlightedId(id); setTimeout(() => setHighlightedId(null), 1500); } function scrollToMessage(id: number) { // Don't chase a message that was deleted during this session. if (deletedMessageIds.current.has(id)) return; const el = document.getElementById(`msg-${id}`); if (el) { el.scrollIntoView({ behavior: "smooth", block: "center" }); flashMessage(id); } else if (hasNextPage) { // Message not rendered yet — start fetching older pages until it appears. setPendingScrollId(id); void fetchNextPage(); } // else: exhausted history, message not found — silently ignore. } // After every page load, try to resolve a pending scroll-to. useEffect(() => { if (!pendingScrollId) return; const el = document.getElementById(`msg-${pendingScrollId}`); if (el) { el.scrollIntoView({ behavior: "smooth", block: "center" }); flashMessage(pendingScrollId); setPendingScrollId(null); } else if (!isFetchingNextPage) { if (hasNextPage) { void fetchNextPage(); // keep paging back until found } else { setPendingScrollId(null); // exhausted history, give up } } // Re-run whenever a new page arrives or fetching state changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [messages.length, isFetchingNextPage]); // Top sentinel triggers loading older messages (scroll-back). const topSentinelRef = useIntersectionLoader( () => { if (hasNextPage && !isFetchingNextPage) void fetchNextPage(); }, { enabled: hasNextPage && !isLoading }, ); // Append a freshly received message into the cursor cache. const appendMessage = useCallback( (msg: MessageModel) => { queryClient.setQueryData<{ pages: CursorPaginated[]; pageParams: unknown[]; }>(messagesQueryKey(chatId), (old) => { if (!old) return old as never; const [first, ...rest] = old.pages; if (!first) return old; if (first.results.some((m) => m.id === msg.id)) return old; return { ...old, pages: [{ ...first, results: [msg, ...first.results] }, ...rest], }; }); }, [queryClient, chatId], ); const removeMessage = useCallback( (messageId: number) => { deletedMessageIds.current.add(messageId); // Also cancel any pending scroll to this message. setPendingScrollId((prev) => (prev === messageId ? null : prev)); queryClient.setQueryData<{ pages: CursorPaginated[]; pageParams: unknown[]; }>(messagesQueryKey(chatId), (old) => { if (!old) return old as never; return { ...old, pages: old.pages.map((p) => ({ ...p, results: p.results // Remove the deleted message itself. .filter((m) => m.id !== messageId) // Tombstone reply_to.content for any message quoting the deleted one. .map((m) => m.reply_to?.id === messageId ? { ...m, reply_to: { ...m.reply_to, content: undefined } } : m, ), })), }; }); }, [queryClient, chatId], ); const handleSocketEvent = useCallback( (event: ChatSocketEvent) => { if (event.type === "new_chat_message" || event.type === "new_reply_chat_message") { let replyTo: MessageModel["reply_to"] = null; if (event.reply_to_id != null) { const cached = queryClient.getQueryData<{ pages: CursorPaginated[] }>( messagesQueryKey(chatId), ); const found = cached?.pages.flatMap((p) => p.results).find((m) => m.id === event.reply_to_id); replyTo = found ? { id: found.id, content: found.content, sender: found.sender } : { id: event.reply_to_id, content: undefined, sender: { id: 0, username: "…", avatar: null } }; } appendMessage({ id: event.message_id, chat: chatId, sender: { id: event.sender_id, username: event.sender, avatar: event.sender_avatar } as never, reply_to: replyTo, content: event.message, is_edited: false, edited_at: null, created_at: new Date(), updated_at: new Date(), media_files: (event.media_files ?? []) as never, reactions: [], }); } else if (event.type === "delete_chat_message") { removeMessage(event.message_id); } else if (event.type === "read_status") { if (event.user === user?.username) { queryClient.invalidateQueries({ queryKey: getApiSocialChatsListQueryKey() }); } } else if (event.type === "typing") { setTypingUsers((prev) => event.is_typing ? prev.includes(event.user) ? prev : [...prev, event.user] : prev.filter((u) => u !== event.user), ); } else if (event.type === "stop_typing") { setTypingUsers((prev) => prev.filter((u) => u !== event.user)); } else if (event.type === "reaction") { queryClient.setQueryData<{ pages: CursorPaginated[]; pageParams: unknown[]; }>(messagesQueryKey(chatId), (old) => { if (!old) return old as never; return { ...old, pages: old.pages.map((p) => ({ ...p, results: p.results.map((m) => { if (m.id !== event.message_id) return m; let reactions = [...(m.reactions ?? [])] as typeof m.reactions; if (event.action === "added") { const alreadyExists = reactions.some( (r) => (r.user as unknown as number) === event.user_id && r.emoji === event.emoji, ); if (!alreadyExists) { reactions = [...reactions, { id: -Date.now(), user: event.user_id, emoji: event.emoji, created_at: new Date().toISOString() } as never]; } } else if (event.action === "removed") { reactions = reactions.filter((r) => !((r.user as unknown as number) === event.user_id && r.emoji === event.emoji)); } else if (event.action === "switched") { reactions = reactions.map((r) => (r.user as unknown as number) === event.user_id ? { ...r, emoji: event.emoji } : r); } return { ...m, reactions }; }), })), }; }); } }, [appendMessage, removeMessage, chatId, user, queryClient], ); const { status, sendMessage, sendReply, sendReaction, sendTyping, sendMarkRead } = useChatSocket({ chatId: Number.isFinite(chatId) ? chatId : null, onEvent: handleSocketEvent, }); // Mark the chat as read as soon as the socket opens. useEffect(() => { if (status === "open") sendMarkRead(); }, [status, sendMarkRead]); // Auto-scroll to bottom when new messages arrive. // Suppressed while chasing a reply-jump so both effects don't fight each other. useEffect(() => { if (!pendingScrollId) { bottomRef.current?.scrollIntoView({ behavior: "auto" }); } if (status === "open") sendMarkRead(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [messages.length]); async function handleSend(text: string, replyToId?: number, files?: File[]): Promise { // Files must go via HTTP multipart; WS handles text-only messages. if (files?.length) { try { const msg = await sendChatMessage({ chatId, content: text || undefined, replyToId, files }); appendMessage(msg); // WS broadcast will deduplicate via the id-check in appendMessage return true; } catch { return false; } } return replyToId ? sendReply(text, replyToId) : sendMessage(text); } if (!Number.isFinite(chatId)) { return ; } return (
{chat?.name || `Chat #${chatId}`}
{status !== "open" && (
{t("chat.room.disconnected")}
)}
{chat && ( [0]["chat"]} open={settingsOpen} onClose={() => setSettingsOpen(false)} /> )}
{hasNextPage && (
{isFetchingNextPage ? ( ) : ( {t("chat.room.loadingHistory")} )}
)} {isLoading && (
)} {!isLoading && messages.length === 0 && ( )} {/* Beginning-of-chat banner — shown only once all pages are confirmed loaded */} {!hasNextPage && !isLoading && chat && (

{chat.name || `Chat #${chatId}`}

{t("chat.room.chatBeginning")}

{t("chat.room.chatCreated", { date: new Date(chat.created_at).toLocaleDateString("cs-CZ", { day: "numeric", month: "long", year: "numeric", }), })}

)} {messages.map((m) => ( sendReaction(msg.id, emoji)} highlighted={m.id === highlightedId} onScrollToMessage={scrollToMessage} /> ))}
{typingUsers.length > 0 && (
{typingUsers.length === 1 ? t("chat.room.typing", { user: typingUsers[0] }) : t("chat.room.typingMany")}
)} setReplyTo(null)} onSend={handleSend} onTyping={sendTyping} />
); }