import { extractErrorCode } from '@chat-app/shared/i18n'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import { ConversationHeader } from '../components/ConversationHeader'; import { GroupInfoPanel } from '../components/GroupInfoPanel'; import { AlertIcon, ArrowRightIcon, PlusIcon, SpinnerIcon, XIcon } from '../components/icons'; import { InCallPanel } from '../components/InCallPanel'; import { MessageBubble } from '../components/MessageBubble'; import { TypingIndicator } from '../components/TypingIndicator'; import { useAuth } from '../context/AuthContext'; import { useConversationsContext } from '../context/ConversationsContext'; import { useConversationMessages } from '../lib/useConversationMessages'; import { useMessageReactions } from '../lib/useMessageReactions'; import { markRead as markMessagesReadRemote, useMessageReads } from '../lib/useMessageReads'; import { usePeerPresence } from '../lib/usePeerPresence'; import { useTypingChannel } from '../lib/useTypingChannel'; const STICK_THRESHOLD = 80; export function ConversationPage() { const { t } = useTranslation(['app', 'errors']); const { id } = useParams<{ id: string }>(); const { session, device } = useAuth(); const { conversations, setActiveConversation, markRead } = useConversationsContext(); const conversation = useMemo( () => conversations.find((c) => c.id === id) ?? null, [conversations, id], ); const peerId = conversation?.peer?.userId; const peerPresence = usePeerPresence(peerId); const { messages, loading, error, send } = useConversationMessages({ conversationId: id, userId: session?.user.id, deviceId: device?.id, }); const messageIds = useMemo(() => messages.map((m) => m.id), [messages]); const { byMessage: reactionsByMessage, toggle: toggleReaction } = useMessageReactions( messageIds, session?.user.id, ); const myId = session?.user.id; // Peer read tracking — only for 1:1 DMs. const ownMessageIds = useMemo( () => messages.filter((m) => m.senderId === myId).map((m) => m.id), [messages, myId], ); const { peerReadSet } = useMessageReads(ownMessageIds, peerId); const lastSeenMessageId = useMemo(() => { for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i]; if (m && m.senderId === myId && peerReadSet.has(m.id)) return m.id; } return null; }, [messages, peerReadSet, myId]); // Typing channel. const { typingUserIds, notifyTyping, notifyStopTyping } = useTypingChannel(id, myId); // Mark incoming messages as read (server-side, visible to peer if both sides // have receipts on). Runs whenever new messages arrive or id changes. useEffect(() => { if (!id || messages.length === 0 || !myId) return; const incoming = messages.filter((m) => m.senderId !== myId).map((m) => m.id); if (incoming.length === 0) return; void markMessagesReadRemote(incoming).catch((err: unknown) => { console.error('markMessagesRead failed', err); }); }, [id, messages, myId]); const [text, setText] = useState(''); const [sending, setSending] = useState(false); const [sendError, setSendError] = useState(null); const [stickToBottom, setStickToBottom] = useState(true); const [attachments, setAttachments] = useState([]); const [infoPanelOpen, setInfoPanelOpen] = useState(false); const scrollRef = useRef(null); const fileInputRef = useRef(null); useEffect(() => { if (!id) return; setActiveConversation(id); return () => { setActiveConversation(null); }; }, [id, setActiveConversation]); useEffect(() => { if (id && messages.length > 0) markRead(id); }, [id, messages.length, markRead]); useEffect(() => { const el = scrollRef.current; if (!el || !stickToBottom) return; el.scrollTop = el.scrollHeight; }, [messages.length, stickToBottom]); useEffect(() => { setStickToBottom(true); const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; }, [id]); const handleScroll = useCallback(() => { const el = scrollRef.current; if (!el) return; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; setStickToBottom(distanceFromBottom < STICK_THRESHOLD); }, []); async function handleSend(e?: React.FormEvent) { e?.preventDefault(); if ((!text.trim() && attachments.length === 0) || sending) return; setSending(true); setSendError(null); try { await send(text, attachments); setText(''); setAttachments([]); if (fileInputRef.current) fileInputRef.current.value = ''; setStickToBottom(true); notifyStopTyping(); } catch (err: unknown) { const code = extractErrorCode(err); setSendError( code ? t('errors:' + code, { defaultValue: t('errors:generic') }) : err instanceof Error ? err.message : t('errors:generic'), ); } finally { setSending(false); } } function handleFilesChosen(list: FileList | null) { if (!list) return; const next: File[] = []; for (let i = 0; i < list.length; i++) { const f = list[i]; if (!f) continue; if (!f.type.startsWith('image/')) continue; if (f.size > 10 * 1024 * 1024) { setSendError('Datei zu groß (max 10 MB)'); continue; } next.push(f); } setAttachments((prev) => [...prev, ...next].slice(0, 4)); } const isGroup = conversation?.type === 'group'; return (
setInfoPanelOpen(true) } : {})} /> {isGroup && conversation && ( setInfoPanelOpen(false)} conversation={conversation} /> )} {conversation && }
{loading ? (
) : error ? ( {error} ) : messages.length === 0 ? (

) : (
    {messages.map((m, idx) => { const prev = messages[idx - 1]; const grouped = idx > 0 && prev?.senderId === m.senderId; return (
  • toggleReaction(m.id, emoji)} showSeen={m.id === lastSeenMessageId} />
  • ); })}
)}
{sendError && (
{sendError}
)} {attachments.length > 0 && (
{attachments.map((file, idx) => ( setAttachments((prev) => prev.filter((_, i) => i !== idx)) } /> ))}
)}
handleFilesChosen(e.target.files)} />