import { parseMessagePayload } from '@chat-app/shared/chat'; 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 { ForwardDialog } from '../components/ForwardDialog'; import { GroupInfoPanel } from '../components/GroupInfoPanel'; import { AlertIcon, ArrowRightIcon, ChevronDownIcon, ChevronUpIcon, PlusIcon, ReplyIcon, SearchIcon, SpinnerIcon, XIcon, } from '../components/icons'; import { InCallPanel } from '../components/InCallPanel'; import { IncomingCallPanel } from '../components/IncomingCallPanel'; import { MessageBubble, type QuotedRef } from '../components/MessageBubble'; import { TypingIndicator } from '../components/TypingIndicator'; import type { DecryptedMessage } from '@chat-app/shared/chat'; import { useAuth } from '../context/AuthContext'; import { useCall } from '../context/CallContext'; 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 [replyTo, setReplyTo] = useState(null); const [forwardTarget, setForwardTarget] = useState(null); const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [searchIdx, setSearchIdx] = useState(0); const [highlightedId, setHighlightedId] = useState(null); const scrollRef = useRef(null); const fileInputRef = useRef(null); const composerRef = useRef(null); // Drop reply-to / clear search state when switching conversation. useEffect(() => { setReplyTo(null); setForwardTarget(null); setSearchOpen(false); setSearchQuery(''); }, [id]); const messageById = useMemo(() => { const m = new Map(); for (const msg of messages) m.set(msg.id, msg); return m; }, [messages]); const senderNameFor = useCallback( (senderId: string): string => { if (senderId === myId) return t('app:chats.you', { defaultValue: 'Du' }); const profile = conversation?.members.find((mm) => mm.userId === senderId)?.profile ?? (senderId !== myId ? conversation?.peer ?? null : null); return profile?.displayName ?? '?'; }, [conversation, myId, t], ); const buildQuoted = useCallback( (replyToId: string | null): QuotedRef | null => { if (!replyToId) return null; const target = messageById.get(replyToId); if (!target) { return { id: replyToId, senderName: '…', snippet: t('app:chats.quote_unavailable', { defaultValue: 'Nachricht nicht verfügbar' }), isAttachment: false, deleted: true, }; } const parsed = parseMessagePayload(target.plaintext); const text = parsed.kind === 'text' ? parsed.text : ''; const hasAttachment = parsed.kind === 'text' && parsed.attachments.length > 0; return { id: target.id, senderName: senderNameFor(target.senderId), snippet: text.length > 120 ? text.slice(0, 120) + '…' : text, isAttachment: hasAttachment, deleted: !!target.deletedAt, }; }, [messageById, senderNameFor, t], ); const jumpToMessage = useCallback((targetId: string) => { const el = scrollRef.current?.querySelector( '[data-message-id="' + CSS.escape(targetId) + '"]', ); if (!el) return; el.scrollIntoView({ behavior: 'smooth', block: 'center' }); setHighlightedId(targetId); window.setTimeout(() => setHighlightedId((cur) => (cur === targetId ? null : cur)), 1600); }, []); const handleReply = useCallback((m: DecryptedMessage) => { setReplyTo(m); composerRef.current?.focus(); }, []); const handleForward = useCallback((m: DecryptedMessage) => { setForwardTarget(m); }, []); // Search matches: messages whose decrypted text includes the query. const searchMatches = useMemo(() => { const q = searchQuery.trim().toLowerCase(); if (!q) return [] as DecryptedMessage[]; return messages.filter((m) => { if (!m.plaintext) return false; const parsed = parseMessagePayload(m.plaintext); if (parsed.kind !== 'text') return false; return parsed.text.toLowerCase().includes(q); }); }, [messages, searchQuery]); // Reset/clamp the active match index when the match set changes. useEffect(() => { if (searchMatches.length === 0) { setSearchIdx(0); return; } setSearchIdx((cur) => Math.min(cur, searchMatches.length - 1)); }, [searchMatches.length]); // Auto-jump to current match. useEffect(() => { if (!searchOpen || searchMatches.length === 0) return; const target = searchMatches[searchIdx]; if (target) jumpToMessage(target.id); }, [searchOpen, searchMatches, searchIdx, jumpToMessage]); 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, replyTo?.id ?? null); setText(''); setAttachments([]); setReplyTo(null); 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'; const { state: callState } = useCall(); // Hide the chat header while this conversation hosts an active call — the // call topbar inside the dock already shows the channel name + duration, // and fullscreen cinema needs the whole slot. const callHereActive = (callState.kind === 'connected' || callState.kind === 'connecting' || callState.kind === 'outgoing') && callState.conversationId === id; const incomingHere = callState.kind === 'incoming' && callState.conversationId === id; return (
{!callHereActive && ( setSearchOpen((v) => !v)} {...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})} /> )} {!callHereActive && searchOpen && ( setSearchIdx((cur) => searchMatches.length === 0 ? 0 : (cur - 1 + searchMatches.length) % searchMatches.length, ) } onNext={() => setSearchIdx((cur) => (searchMatches.length === 0 ? 0 : (cur + 1) % searchMatches.length)) } onClose={() => { setSearchOpen(false); setSearchQuery(''); }} /> )} {isGroup && conversation && ( setInfoPanelOpen(false)} conversation={conversation} /> )} {incomingHere && conversation && } {conversation && }
{loading ? (
) : error ? ( {error} ) : messages.length === 0 ? (

) : (
    {messages.map((m, idx) => { // A "run" is consecutive bubbles from the same sender with // nothing between them. Call-event separators break the run — // a bubble whose immediate next neighbour is a call_event must // anchor the avatar, even if another bubble from the same // sender appears after the separator. const prevRaw = messages[idx - 1]; const nextRaw = messages[idx + 1]; const prevIsCallEvent = !!prevRaw && parseMessagePayload(prevRaw.plaintext).kind === 'call_event'; const nextIsCallEvent = !!nextRaw && parseMessagePayload(nextRaw.plaintext).kind === 'call_event'; const grouped = !!prevRaw && prevRaw.senderId === m.senderId && !prevIsCallEvent; // Anchor avatar on the LAST message of a run so it aligns with // the bubble's tail (bottom corner). Tail is bottom-left for // mine, bottom-right for peer — see rounded-[…_4px_…] above. const isLastOfRun = !nextRaw || nextRaw.senderId !== m.senderId || nextIsCallEvent; // DM fallback: if member lookup fails (e.g. transient sync), fall // back to conversation.peer so the peer's avatar still resolves. const memberProfile = conversation?.members.find((mm) => mm.userId === m.senderId)?.profile ?? null; const senderProfile = memberProfile ?? (m.senderId !== myId ? (conversation?.peer ?? null) : null); return (
  • toggleReaction(m.id, emoji)} showSeen={m.id === lastSeenMessageId} quoted={buildQuoted(m.replyToId)} onJumpToMessage={jumpToMessage} onReply={handleReply} onForward={handleForward} highlighted={highlightedId === m.id} />
  • ); })}
)}
{sendError && (
{sendError}
)} {replyTo && (
)} {attachments.length > 0 && (
{attachments.map((file, idx) => ( setAttachments((prev) => prev.filter((_, i) => i !== idx)) } /> ))}
)}
handleFilesChosen(e.target.files)} />