import { parseMessagePayload, pinMessage, unpinMessage } from '@chat-app/shared/chat'; import { extractErrorCode } from '@chat-app/shared/i18n'; import { useCallback, useEffect, useLayoutEffect, 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 { EmptyState } from '../components/EmptyState'; import { ForwardDialog } from '../components/ForwardDialog'; import { GifPicker } from '../components/GifPicker'; import { GroupInfoPanel } from '../components/GroupInfoPanel'; import { AlertIcon, ArrowRightIcon, ChevronDownIcon, ChevronUpIcon, EyeOffIcon, PencilIcon, PlusIcon, PollIcon, ReplyIcon, SearchIcon, SendIcon, SmileIcon, SpinnerIcon, XIcon, } from '../components/icons'; import { ImageAnnotator } from '../components/ImageAnnotator'; import { InCallPanel } from '../components/InCallPanel'; import { IncomingCallPanel } from '../components/IncomingCallPanel'; import { CallPreviewPanel } from '../components/CallPreviewPanel'; import { MediaFilesDrawer } from '../components/MediaFilesDrawer'; import { MentionAutocomplete } from '../components/MentionAutocomplete'; import { MessageBubble, type QuotedRef } from '../components/MessageBubble'; import { PinnedMessagesPanel } from '../components/PinnedMessagesPanel'; import { PollComposerDialog } from '../components/PollComposerDialog'; 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 { collectConversationAttachments, createPollPayload, createWhiteboardPayload, createWatchTogetherPayload, createGamePayload, } from '../lib/conversationFeatures'; import { WhiteboardModal } from '../components/WhiteboardModal'; import { WatchTogetherModal } from '../components/WatchTogetherModal'; import { GameModal } from '../components/GameModal'; import { createWhiteboard, createWatchSession, parseYouTubeUrl, createGame, type GameType } from '@chat-app/shared/chat'; import { compressImages } from '../lib/imageCompress'; import { ensureInstallId } from '../lib/installId'; import { searchCachedMessages } from '../lib/messageCache'; import { supabase } from '../lib/supabase'; import { type GifResult } from '../lib/tenor'; 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 { usePinnedMessages } from '../lib/usePinnedMessages'; import { useTypingChannel } from '../lib/useTypingChannel'; const STICK_THRESHOLD = 80; // Per-conversation scroll memory. Module-scoped so it survives re-mounts // of ConversationPage when the route param (`id`) changes — switching // chats unmounts/remounts the page in our router setup. Session-only // (lost on reload, like Discord). The `stickToBottom` flag is preserved // alongside the pixel offset so a chat the user left at the bottom keeps // auto-following new messages when they return; a chat scrolled up // returns to the exact spot the user was reading. const scrollPositions = new Map(); export function ConversationPage() { const { t } = useTranslation(['app', 'errors']); const { id } = useParams<{ id: string }>(); const { session } = 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: ensureInstallId(), }); const messageIds = useMemo(() => messages.map((m) => m.id), [messages]); const { byMessage: reactionsByMessage, toggle: toggleReaction, voteExclusive: votePoll, } = useMessageReactions(messageIds, session?.user.id); const myId = session?.user.id; 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); 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]); 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]); const { typingUserIds, notifyTyping, notifyStopTyping } = useTypingChannel(id, myId); 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 [annotatingIndex, setAnnotatingIndex] = useState(null); const [infoPanelOpen, setInfoPanelOpen] = useState(false); const [mediaDrawerOpen, setMediaDrawerOpen] = useState(false); const [pollDialogOpen, setPollDialogOpen] = useState(false); const [pollSending, setPollSending] = useState(false); const [openWhiteboardId, setOpenWhiteboardId] = useState(null); const [creatingWhiteboard, setCreatingWhiteboard] = useState(false); const [openWatchSessionId, setOpenWatchSessionId] = useState(null); const [watchDialogOpen, setWatchDialogOpen] = useState(false); const [watchUrl, setWatchUrl] = useState(''); const [watchError, setWatchError] = useState(null); const [watchCreating, setWatchCreating] = useState(false); const [openGameId, setOpenGameId] = useState(null); const [gameDialogOpen, setGameDialogOpen] = useState(false); const [gameError, setGameError] = useState(null); const [gameCreating, setGameCreating] = useState(false); const [pollError, setPollError] = useState(null); 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); const [firstUnreadId, setFirstUnreadId] = useState(null); const [firstUnreadJumpDismissed, setFirstUnreadJumpDismissed] = useState(false); const [newMessagesWhileAway, setNewMessagesWhileAway] = useState(0); const firstUnreadComputedRef = useRef(false); const previousMessageIdsRef = useRef>(new Set()); 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 [gifPickerOpen, setGifPickerOpen] = useState(false); // Sticky toggle: when on, the next image(s) sent are marked view-once. // Auto-clears on a successful send so the composer doesn't accidentally // burn the message-after-next. const [viewOnceNext, setViewOnceNext] = useState(false); const pins = usePinnedMessages(id); const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false); const pinnedIds = useMemo(() => new Set(pins.map((p) => p.messageId)), [pins]); const handleTogglePin = useCallback( async (messageId: string) => { if (!id || !myId) return; try { if (pinnedIds.has(messageId)) { await unpinMessage(supabase, id, messageId); } else { await pinMessage(supabase, id, messageId, myId); } } catch (err) { console.warn('pin toggle failed', err); } }, [id, myId, pinnedIds], ); const handleGifPick = useCallback( async (gif: GifResult) => { try { // Download the GIF bytes once and feed them into the existing // image-attachment pipeline so the result is end-to-end-encrypted // like any normal image. const res = await fetch(gif.url); const blob = await res.blob(); const file = new File([blob], `tenor-${gif.id}.gif`, { type: 'image/gif' }); await send('', [file], null); } catch (err) { console.warn('GIF send failed', err); } }, [send], ); const scrollRef = useRef(null); const loadMoreSentinelRef = useRef(null); const fileInputRef = useRef(null); const composerRef = useRef(null); useEffect(() => { setReplyTo(null); setForwardTarget(null); setSearchOpen(false); setMediaDrawerOpen(false); setPollDialogOpen(false); setSearchQuery(''); setDisplayCount(150); setFirstUnreadId(null); setFirstUnreadJumpDismissed(false); setNewMessagesWhileAway(0); previousMessageIdsRef.current = new Set(); firstUnreadComputedRef.current = false; }, [id]); 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) { setFirstUnreadId(null); return; } const boundary = messages[messages.length - count]; setFirstUnreadId(boundary ? boundary.id : null); }, [id, messages, unread]); useEffect(() => { const previous = previousMessageIdsRef.current; if (previous.size > 0 && !stickToBottom) { const addedIncoming = messages.filter( (message) => !previous.has(message.id) && message.senderId !== myId, ).length; if (addedIncoming > 0) { setNewMessagesWhileAway((count) => count + addedIncoming); } } previousMessageIdsRef.current = new Set(messages.map((message) => message.id)); }, [messages, myId, stickToBottom]); 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 attachmentIndex = useMemo(() => collectConversationAttachments(messages), [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 : parsed.kind === 'poll' ? 'Umfrage: ' + parsed.question : ''; 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); }, []); const searchActive = useMemo( () => searchQuery.trim().length > 0 || searchSenderId !== '' || searchAttachmentsOnly || searchDateFrom !== '' || searchDateTo !== '', [searchQuery, searchSenderId, searchAttachmentsOnly, searchDateFrom, searchDateTo], ); 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; const toTs = searchDateTo ? new Date(searchDateTo).getTime() + 24 * 3600 * 1000 - 1 : null; 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, ]); useEffect(() => { if (searchMatches.length === 0) { setSearchIdx(0); return; } setSearchIdx((cur) => Math.min(cur, searchMatches.length - 1)); }, [searchMatches.length]); 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(() => { const onOpen = (e: Event) => { const detail = (e as CustomEvent<{ id?: string }>).detail; if (detail?.id) setOpenWhiteboardId(detail.id); }; window.addEventListener('chatapp:open-whiteboard', onOpen); return () => window.removeEventListener('chatapp:open-whiteboard', onOpen); }, []); useEffect(() => { const onOpen = (e: Event) => { const detail = (e as CustomEvent<{ id?: string }>).detail; if (detail?.id) setOpenWatchSessionId(detail.id); }; window.addEventListener('chatapp:open-watch-together', onOpen); return () => window.removeEventListener('chatapp:open-watch-together', onOpen); }, []); useEffect(() => { const onOpen = (e: Event) => { const detail = (e as CustomEvent<{ id?: string }>).detail; if (detail?.id) setOpenGameId(detail.id); }; window.addEventListener('chatapp:open-game', onOpen); return () => window.removeEventListener('chatapp:open-game', onOpen); }, []); useEffect(() => { if (id && messages.length > 0) markRead(id); }, [id, messages.length, markRead]); // useLayoutEffect: run synchronously after DOM commit, before the // browser paints. Using useEffect here let one frame of "scrollTop = 0 // (top of list)" paint between message-list mount and the auto-scroll, // which is exactly the "flickers to a different position, then jumps" // glitch users saw when re-entering a chat. Layout-effect fires while // the message list is in the DOM but before paint, so the first frame // already shows the correct scroll position. useLayoutEffect(() => { const el = scrollRef.current; if (!el || !stickToBottom) return; el.scrollTop = el.scrollHeight; }, [messages.length, stickToBottom]); // Restore saved scroll position once the conversation's messages have // actually rendered. The earlier version fired on `[id]` alone and ran // before the message list populated — scrollHeight was still tiny, so // `el.scrollTop = saved.scrollTop` got clamped to 0 by the browser // and the user landed at the top instead of the saved position. By // waiting for `messages.length > 0` we know the rendered scrollHeight // is meaningful. `restoredForRef` ensures the restore runs at most // once per chat switch (subsequent message arrivals don't re-trigger). const restoredForRef = useRef(null); const isRestoringRef = useRef(false); // useLayoutEffect, same reason as above: writing scrollTop here happens // before the first paint of the freshly-mounted chat, so the user // doesn't see a frame at scrollTop=0 before the jump to the saved // position. Combined with the messages.length gate this means the // re-entry shows the message list AT the saved scroll location in one // single paint — no "loaded then jumped" effect. useLayoutEffect(() => { const el = scrollRef.current; if (!el || !id) return; if (restoredForRef.current === id) return; // Wait for the conversation's messages to populate; for a chat that // truly has zero messages the bottom and the top are the same anyway. if (messages.length === 0) return; restoredForRef.current = id; const saved = scrollPositions.get(id); // Suppress handleScroll's persistence during the programmatic scroll // below — otherwise the browser's clamp/normalisation could write a // different scrollTop back into the Map and lose the saved position. isRestoringRef.current = true; if (saved && !saved.stickToBottom) { el.scrollTop = saved.scrollTop; setStickToBottom(false); } else { setStickToBottom(true); el.scrollTop = el.scrollHeight; } requestAnimationFrame(() => { isRestoringRef.current = false; }); }, [id, messages.length]); const handleScroll = useCallback(() => { const el = scrollRef.current; if (!el) return; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; const nextStick = distanceFromBottom < STICK_THRESHOLD; setStickToBottom(nextStick); if (nextStick) setNewMessagesWhileAway(0); // Persist position per chat so re-entering this conversation lands // where the user left off (see scrollPositions module-level Map). // Skipped during the in-flight restore so we don't immediately // overwrite the saved position with a clamped value. if (id && !isRestoringRef.current) { scrollPositions.set(id, { scrollTop: el.scrollTop, stickToBottom: nextStick }); } }, [id]); const jumpToBottom = useCallback(() => { const el = scrollRef.current; if (!el) return; el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); setStickToBottom(true); setNewMessagesWhileAway(0); }, []); 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, { viewOnce: viewOnceNext }); setText(''); setAttachments([]); setReplyTo(null); // Reset the sticky view-once flag so it only applies to the message // the user explicitly armed it for — Snapchat / WhatsApp parity. setViewOnceNext(false); 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); } } const handlePollSubmit = useCallback( async (question: string, options: string[]) => { setPollSending(true); setPollError(null); try { const payload = createPollPayload(question, options); await send(payload, [], replyTo?.id ?? null); setPollDialogOpen(false); setReplyTo(null); setStickToBottom(true); notifyStopTyping(); } catch (err: unknown) { setPollError(err instanceof Error ? err.message : 'Umfrage konnte nicht gesendet werden'); } finally { setPollSending(false); } }, [send, replyTo?.id, notifyStopTyping], ); const handleCreateWhiteboard = useCallback(async () => { if (!id || creatingWhiteboard) return; setCreatingWhiteboard(true); try { const board = await createWhiteboard(supabase, id); const payload = createWhiteboardPayload(board.id); await send(payload, [], replyTo?.id ?? null); setReplyTo(null); setStickToBottom(true); setOpenWhiteboardId(board.id); } catch (err: unknown) { setSendError(err instanceof Error ? err.message : 'Whiteboard konnte nicht angelegt werden'); } finally { setCreatingWhiteboard(false); } }, [id, creatingWhiteboard, send, replyTo?.id]); const handleStartWatchTogether = useCallback(async () => { if (!id) return; const videoId = parseYouTubeUrl(watchUrl); if (!videoId) { setWatchError('Ungültige YouTube-URL.'); return; } setWatchCreating(true); setWatchError(null); try { const ws = await createWatchSession(supabase, { conversationId: id, videoId }); const payload = createWatchTogetherPayload(ws.id); await send(payload, [], replyTo?.id ?? null); setReplyTo(null); setStickToBottom(true); setWatchDialogOpen(false); setWatchUrl(''); setOpenWatchSessionId(ws.id); } catch (err: unknown) { setWatchError(err instanceof Error ? err.message : 'Konnte Watch-Together nicht starten'); } finally { setWatchCreating(false); } }, [id, watchUrl, send, replyTo?.id]); const handleStartGame = useCallback(async (gameType: GameType) => { if (!id) return; if (!conversation || conversation.members.length !== 2) { setGameError('Spiele aktuell nur in 1:1-Chats.'); return; } const opponent = conversation.members.find((m) => m.userId !== myId); if (!opponent) { setGameError('Kein Gegner gefunden.'); return; } setGameCreating(true); setGameError(null); try { const game = await createGame(supabase, { conversationId: id, gameType, opponentUserId: opponent.userId, }); const payload = createGamePayload(game.id, gameType); await send(payload, [], replyTo?.id ?? null); setReplyTo(null); setStickToBottom(true); setGameDialogOpen(false); setOpenGameId(game.id); } catch (err: unknown) { setGameError(err instanceof Error ? err.message : 'Konnte Spiel nicht starten'); } finally { setGameCreating(false); } }, [id, conversation, myId, send, replyTo?.id]); async function ingestFiles(files: File[]) { 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(); 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) => { 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 && ( setMediaDrawerOpen((v) => !v)} {...(conversation?.type === 'dm' && conversation.peer ? { onProfileClick: (ev: React.MouseEvent) => { ev.stopPropagation(); setProfilePopover({ userId: conversation.peer!.userId, x: ev.clientX, y: ev.clientY, }); }, } : {})} onSearchClick={() => setSearchOpen((v) => !v)} {...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})} pinnedCount={pins.length} onOpenPinned={() => setPinnedPanelOpen(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(''); }} /> )} {/* Layout row: chat column on the left grows to fill remaining width; right-hand drawers (Media/Files, Group Info) render as inline siblings so opening one narrows the chat instead of floating on top of it (Discord parity). The chat-column wrapper holds the `relative` anchor for the drag-and-drop overlay further below. */}
{/* Discord-style persistent voice-channel rail. Always visible in groups so anyone can pop in without an invite-ring; hidden in 1:1s unless someone is already waiting. Hides automatically once we're in. */} {conversation && !incomingHere && } {incomingHere && conversation && } {conversation && }
{loading ? (
) : error ? ( {error} ) : messages.length === 0 ? ( } title={t('app:chats.conv_empty_title', { defaultValue: 'Sag Hallo 👋' })} description={t('app:chats.conv_empty_desc', { defaultValue: 'Hier ist noch nichts. Schreibe die erste Nachricht.', })} /> ) : (
    {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; 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; const isLastOfRun = !nextRaw || nextRaw.senderId !== m.senderId || nextIsCallEvent; const memberProfile = conversation?.members.find((mm) => mm.userId === m.senderId)?.profile ?? null; const senderProfile = memberProfile ?? (m.senderId !== myId ? (conversation?.peer ?? null) : null); return (
  • {firstUnreadId === m.id && (
    Neue Nachrichten
    )} toggleReaction(m.id, emoji)} onVotePoll={(emoji, optionEmojis) => votePoll(m.id, emoji, optionEmojis)} 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} isPinned={pinnedIds.has(m.id)} onTogglePin={handleTogglePin} />
  • ); })} {pending.map((p) => (
  • retryPending(p.id)} onCancel={() => cancelPending(p.id)} />
  • ))}
)}
{firstUnreadId && !firstUnreadJumpDismissed && ( )} {(!stickToBottom || newMessagesWhileAway > 0) && ( )} {isDraggingFile && ( )}
{sendError && (
{sendError}
)} {replyTo && (
)} {attachments.length > 0 && (
{attachments.map((file, idx) => ( setAttachments((prev) => prev.filter((_, i) => i !== idx))} {...(file.type.startsWith('image/') ? { onEdit: () => setAnnotatingIndex(idx) } : {})} /> ))}
)}
{isGroup && mentionState && conversation && ( { 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); 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)} />
setGifPickerOpen(false)} onPick={(gif) => void handleGifPick(gif)} />
{ try { await send('', [file], replyTo?.id ?? null); setReplyTo(null); } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'send failed'; setSendError(msg); } }} />