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 { EmojiPicker } from '../components/EmojiPicker'; import { ForwardDialog } from '../components/ForwardDialog'; import { GroupInfoPanel } from '../components/GroupInfoPanel'; import { AlertIcon, ArrowRightIcon, ChevronDownIcon, ChevronUpIcon, PlusIcon, ReplyIcon, SearchIcon, SmileIcon, SpinnerIcon, XIcon, } from '../components/icons'; import { InCallPanel } from '../components/InCallPanel'; import { IncomingCallPanel } from '../components/IncomingCallPanel'; import { MentionAutocomplete } from '../components/MentionAutocomplete'; import { MessageBubble, type QuotedRef } from '../components/MessageBubble'; import { UserProfilePopover } from '../components/UserProfilePopover'; import { TypingIndicator } from '../components/TypingIndicator'; import { VoiceRecorder } from '../components/VoiceRecorder'; import type { DecryptedMessage } from '@chat-app/shared/chat'; import { useAuth } from '../context/AuthContext'; import { useCall } from '../context/CallContext'; import { useConversationsContext } from '../context/ConversationsContext'; import { compressImages } from '../lib/imageCompress'; import { searchCachedMessages } from '../lib/messageCache'; import type { OutboxItem } from '../lib/messageOutbox'; import { useConversationMessages } from '../lib/useConversationMessages'; import { useMessageReactions } from '../lib/useMessageReactions'; import { useGroupReceipts } from '../lib/useGroupReceipts'; import { markDelivered, useMessageDeliveries } from '../lib/useMessageDeliveries'; 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, unread } = 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, pending, retryPending, cancelPending } = 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 { peerDeliveredSet } = useMessageDeliveries(ownMessageIds, peerId); // Group receipts: only meaningful when conversation is a group. We feed it // ownMessageIds since we only render delivery state on the sender side. const isGroup = conversation?.type === 'group'; const { deliveredByMessage: groupDelivered, readByMessage: groupRead } = useGroupReceipts(ownMessageIds, myId, !!isGroup); const groupRecipientCount = useMemo(() => { if (!isGroup || !conversation) return 0; return conversation.members.filter((m) => m.userId !== myId).length; }, [isGroup, conversation, myId]); // Mark every peer-authored message as delivered on our side. Idempotent, // so rerunning for already-acknowledged ids is a no-op server-side. const deliveredTrackedRef = useRef>(new Set()); useEffect(() => { if (!myId || messages.length === 0) return; const toMark: string[] = []; for (const m of messages) { if (m.senderId === myId) continue; if (deliveredTrackedRef.current.has(m.id)) continue; deliveredTrackedRef.current.add(m.id); toMark.push(m.id); } if (toMark.length > 0) { void markDelivered(toMark).catch((err: unknown) => { console.warn('markDelivered failed', err); }); } }, [messages, myId]); 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 [searchSenderId, setSearchSenderId] = useState(''); const [searchAttachmentsOnly, setSearchAttachmentsOnly] = useState(false); const [searchDateFrom, setSearchDateFrom] = useState(''); const [searchDateTo, setSearchDateTo] = useState(''); const [highlightedId, setHighlightedId] = useState(null); const [displayCount, setDisplayCount] = useState(150); const [isDraggingFile, setIsDraggingFile] = useState(false); // Snapshot of the "first-unread-message" id captured once the very first // render of this conversation lands. Stays fixed until the user switches // away so the divider doesn't jump around while new messages arrive. const firstUnreadRef = useRef(null); const firstUnreadComputedRef = useRef(false); const [profilePopover, setProfilePopover] = useState< { userId: string; x: number; y: number } | null >(null); const [mentionState, setMentionState] = useState< { query: string; start: number } | null >(null); const [emojiOpen, setEmojiOpen] = useState(false); const scrollRef = useRef(null); const loadMoreSentinelRef = 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(''); setDisplayCount(150); firstUnreadRef.current = null; firstUnreadComputedRef.current = false; }, [id]); // On first message-list populate for this conversation, pin the divider // above the oldest-unread message. We only compute once — subsequent // inserts push the divider "further back" visually, which matches // Discord's behaviour. useEffect(() => { if (firstUnreadComputedRef.current) return; if (!id || messages.length === 0) return; const count = unread[id] ?? 0; firstUnreadComputedRef.current = true; if (count === 0 || count > messages.length) { firstUnreadRef.current = null; return; } const boundary = messages[messages.length - count]; firstUnreadRef.current = boundary ? boundary.id : null; }, [id, messages, unread]); // Expand window when the "load older" sentinel scrolls into view. Doubles // effective window on each trigger so scrolling up quickly converges to // rendering everything. useEffect(() => { const el = loadMoreSentinelRef.current; if (!el) return; if (displayCount >= messages.length) return; const observer = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting) { setDisplayCount((n) => Math.min(messages.length, n * 2)); } }, { root: scrollRef.current, rootMargin: '200px 0px' }, ); observer.observe(el); return () => observer.disconnect(); }, [displayCount, messages.length]); 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 matching query + filters. Empty query is allowed // when filters are active, so users can e.g. show "all attachments from // alice in the last week" without a text query. const searchActive = useMemo( () => searchQuery.trim().length > 0 || searchSenderId !== '' || searchAttachmentsOnly || searchDateFrom !== '' || searchDateTo !== '', [searchQuery, searchSenderId, searchAttachmentsOnly, searchDateFrom, searchDateTo], ); // FTS5-backed supplementary results: covers cached messages that aren't in // the currently-loaded window (`messages`). Runs only when there's a text // query — filters alone stay in-memory because they depend on already- // decrypted payload state. const [ftsExtras, setFtsExtras] = useState([]); useEffect(() => { if (!id) { setFtsExtras([]); return; } const q = searchQuery.trim(); if (q.length < 2) { setFtsExtras([]); return; } let cancelled = false; void searchCachedMessages(id, q, 200).then((rows) => { if (cancelled) return; setFtsExtras(rows); }); return () => { cancelled = true; }; }, [id, searchQuery]); const searchMatches = useMemo(() => { if (!searchActive) return [] as DecryptedMessage[]; const q = searchQuery.trim().toLowerCase(); const fromTs = searchDateFrom ? new Date(searchDateFrom).getTime() : null; // Date inputs cover whole days — bump 'to' to end-of-day. const toTs = searchDateTo ? new Date(searchDateTo).getTime() + 24 * 3600 * 1000 - 1 : null; // Union the live `messages` array with any FTS5-only rows not yet // loaded into memory, keyed by id so we don't double-count. const seen = new Set(); const pool: DecryptedMessage[] = []; for (const m of messages) { if (!seen.has(m.id)) { seen.add(m.id); pool.push(m); } } for (const m of ftsExtras) { if (!seen.has(m.id)) { seen.add(m.id); pool.push(m); } } pool.sort( (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), ); return pool.filter((m) => { if (searchSenderId && m.senderId !== searchSenderId) return false; const created = new Date(m.createdAt).getTime(); if (fromTs !== null && created < fromTs) return false; if (toTs !== null && created > toTs) return false; if (!m.plaintext) return false; const parsed = parseMessagePayload(m.plaintext); if (parsed.kind !== 'text') return false; if (searchAttachmentsOnly && parsed.attachments.length === 0) return false; if (q && !parsed.text.toLowerCase().includes(q)) return false; return true; }); }, [ messages, ftsExtras, searchActive, searchQuery, searchSenderId, searchAttachmentsOnly, searchDateFrom, searchDateTo, ]); // 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); } } async function ingestFiles(files: File[]) { // Pre-compression so heavy phone photos (typically 4-8MB) don't bust the // 10MB limit and don't waste storage/bandwidth. Non-image + animated // files are passed through unchanged. const compressed = await compressImages(files); const next: File[] = []; for (const f of compressed) { if (f.size > 10 * 1024 * 1024) { setSendError('Datei zu groß (max 10 MB)'); continue; } next.push(f); } setAttachments((prev) => [...prev, ...next].slice(0, 4)); } function handleFilesChosen(list: FileList | null) { if (!list) return; void ingestFiles(Array.from(list)); } 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 (
{ if (e.dataTransfer?.types.includes('Files')) { e.preventDefault(); setIsDraggingFile(true); } }} onDragOver={(e) => { if (e.dataTransfer?.types.includes('Files')) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; } }} onDragLeave={(e) => { // leave fires on child enter too; only clear when leaving the page container. if (e.currentTarget === e.target) setIsDraggingFile(false); }} onDrop={(e) => { if (!e.dataTransfer?.files?.length) return; e.preventDefault(); setIsDraggingFile(false); void ingestFiles(Array.from(e.dataTransfer.files)); }} > {!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(''); setSearchSenderId(''); setSearchAttachmentsOnly(false); setSearchDateFrom(''); setSearchDateTo(''); }} /> )} {isGroup && conversation && ( setInfoPanelOpen(false)} conversation={conversation} /> )} {incomingHere && conversation && } {conversation && }
{loading ? (
) : error ? ( {error} ) : messages.length === 0 ? (

) : (
    {displayCount < messages.length && (
  • Lade ältere Nachrichten…
  • )} {messages.slice(Math.max(0, messages.length - displayCount)).map((m, sliceIdx) => { const idx = Math.max(0, messages.length - displayCount) + sliceIdx; // 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 (
  • {firstUnreadRef.current === m.id && (
    Neue Nachrichten
    )} toggleReaction(m.id, emoji)} showSeen={m.id === lastSeenMessageId} {...(m.senderId === myId ? { deliveryState: computeDeliveryState({ messageId: m.id, isGroup: !!isGroup, recipientCount: groupRecipientCount, peerReadSet, peerDeliveredSet, groupRead, groupDelivered, }), } : {})} quoted={buildQuoted(m.replyToId)} onJumpToMessage={jumpToMessage} onReply={handleReply} onForward={handleForward} onAvatarClick={(uid, ev) => { ev.stopPropagation(); setProfilePopover({ userId: uid, x: ev.clientX, y: ev.clientY, }); }} highlighted={highlightedId === m.id} />
  • ); })} {pending.map((p) => (
  • retryPending(p.id)} onCancel={() => cancelPending(p.id)} />
  • ))}
)}
{isDraggingFile && ( )}
{sendError && (
{sendError}
)} {replyTo && (
)} {attachments.length > 0 && (
{attachments.map((file, idx) => ( setAttachments((prev) => prev.filter((_, i) => i !== idx)) } /> ))}
)}
{isGroup && mentionState && conversation && ( { // Replace `@{query}` at `start..caret` with `@{username} `. const start = mentionState.start; const before = text.slice(0, start); const afterCaret = text.slice(start + 1 + mentionState.query.length); const inserted = '@' + username + ' '; const next = before + inserted + afterCaret; setText(next); setMentionState(null); // Restore caret position after inserted mention. const caret = (before + inserted).length; requestAnimationFrame(() => { const el = composerRef.current; if (!el) return; el.focus(); el.setSelectionRange(caret, caret); }); }} onClose={() => setMentionState(null)} /> )} handleFilesChosen(e.target.files)} />
{ const el = composerRef.current; const caret = el?.selectionStart ?? text.length; const next = text.slice(0, caret) + emoji + text.slice(caret); setText(next); requestAnimationFrame(() => { if (!el) return; el.focus(); const pos = caret + emoji.length; el.setSelectionRange(pos, pos); }); }} onClose={() => setEmojiOpen(false)} />
{ try { await send('', [file], replyTo?.id ?? null); setReplyTo(null); } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'send failed'; setSendError(msg); } }} />