import { parseMessagePayload, pinMessage, unpinMessage } from '@chat-app/shared/chat'; import { extractErrorCode } from '@chat-app/shared/i18n'; import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import { MessageList, type MessageListHandle } from '../components/MessageList'; import { ComposerActionsMenu } from '../components/ComposerActionsMenu'; 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, EyeIcon, EyeOffIcon, PencilIcon, PlusIcon, ReplyIcon, SearchIcon, SendIcon, SmileIcon, SpinnerIcon, XIcon, } from '../components/icons'; const ImageAnnotator = lazy(() => import('../components/ImageAnnotator').then((m) => ({ default: m.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 type { AggregatedReaction } from '../lib/useMessageReactions'; 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'; const WhiteboardModal = lazy(() => import('../components/WhiteboardModal').then((m) => ({ default: m.WhiteboardModal })), ); const WatchTogetherModal = lazy(() => import('../components/WatchTogetherModal').then((m) => ({ default: m.WatchTogetherModal })), ); const GameModal = lazy(() => import('../components/GameModal').then((m) => ({ default: m.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'; import { clearDraft, getDraftSync, setDraft } from '../lib/composerDraftStore'; // Discriminated union for rows inside the virtualized message list. Keeping // pending bubbles and the "load older" tile inside the same Virtuoso // instance means scroll-to-bottom / followOutput stay coherent across both // (we don't need a sibling scroll container for pending items). export type VirtuosoRow = | { kind: 'loader'; key: string } | { kind: 'message'; key: string; message: DecryptedMessage; idx: number } | { kind: 'pending'; key: string; item: OutboxItem }; // Stable empty-reactions sentinel. We pass this when a message has no // reactions instead of `[]` literal — a fresh array per render would defeat // `React.memo` on `MessageBubble` since the `reactions` prop reference would // change on every parent render. const EMPTY_REACTIONS: AggregatedReaction[] = []; // Per-conversation scroll memory. Module-scoped so it survives the // per-id remount of ConversationPage (see `ConversationRoute` in // App.tsx). Session-only (lost on reload, like Discord). The // `stickToBottom` flag is preserved alongside the topmost-visible row // index so a chat the user left at the bottom keeps auto-following new // messages when they return; a chat scrolled up returns to roughly the // same row the user was reading. // // We track the topmost-visible row index rather than a pixel `scrollTop` // because `react-virtuoso` virtualizes the list — the underlying scroll // element's pixel offset depends on dynamically-measured row heights and // is not stable across remounts. Using a row index restores the user's // reading position even if some rows above re-render at different heights. const scrollPositions = new Map(); /** Pending composer attachment: the raw File plus the per-attachment * view-once flag the user can toggle from the thumb hover button (P7.T4). * Lives only in composer state — the flag is forwarded into * `message_attachments.view_once` per row when the message is sent. */ interface PendingAttachment { file: File; viewOnce: boolean; } 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, ready: reactionsReady, } = useMessageReactions(messageIds, session?.user.id); // Deferred-reveal gate for MessageList: keep the list hidden until messages // AND their reactions (the main post-paint height changer) are loaded, so the // chat opens already-stable instead of flickering through the load cascade. // A 300 ms max-timeout ensures a slow/empty reactions fetch never hangs it. const [revealTimedOut, setRevealTimedOut] = useState(false); useEffect(() => { setRevealTimedOut(false); if (!id || loading || messages.length === 0) return; const tmo = window.setTimeout(() => setRevealTimedOut(true), 300); return () => window.clearTimeout(tmo); }, [id, loading, messages.length]); const listReady = !loading && messages.length > 0 && (reactionsReady || revealTimedOut); 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(() => { if (!id) return ''; return getDraftSync(id)?.text ?? ''; }); const [sending, setSending] = useState(false); const [sendError, setSendError] = useState(null); const [stickToBottom, setStickToBottom] = useState(true); // Pending composer attachments — each carries its own view-once flag so // the user can mark individual images "burn after viewing" via the hover // toggle on the thumb (P7.T4). Non-image attachments keep viewOnce=false // but the field stays on the object so the shape is uniform. 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); const [actionsMenuOpen, setActionsMenuOpen] = useState(false); const actionsMenuAnchorRef = useRef(null); const { pins, applyOptimisticPin, applyOptimisticUnpin, restorePins } = usePinnedMessages(id); const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false); const pinnedIds = useMemo(() => new Set(pins.map((p) => p.messageId)), [pins]); // Optimistic pin/unpin: flip the local list synchronously so the pin badge // / panel updates on the same frame as the click. Realtime echo via // usePinnedMessages will refetch and reconcile (no-op since the optimistic // row matches the server). On error we restore the snapshot so the badge // doesn't lie about persisted state. const handleTogglePin = useCallback( async (messageId: string) => { if (!id || !myId) return; const wasPinned = pinnedIds.has(messageId); const snapshot = wasPinned ? applyOptimisticUnpin(messageId) : applyOptimisticPin(messageId, myId); try { if (wasPinned) { await unpinMessage(supabase, id, messageId); } else { await pinMessage(supabase, id, messageId, myId); } } catch (err) { restorePins(snapshot); console.warn('pin toggle failed', err); } }, [id, myId, pinnedIds, applyOptimisticPin, applyOptimisticUnpin, restorePins], ); 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 listRef = useRef(null); const topmostIndexRef = useRef(0); const fileInputRef = useRef(null); const composerRef = useRef(null); useEffect(() => { if (!id) return; const draft = getDraftSync(id); const savedReplyToId = draft?.replyToId ?? null; if (!savedReplyToId) return; if (replyTo?.id === savedReplyToId) return; const match = messages.find((m) => m.id === savedReplyToId); if (match) setReplyTo(match); }, [id, messages, replyTo?.id]); useEffect(() => { if (!id) return; setDraft(id, { text, replyToId: replyTo?.id ?? null }); }, [id, text, replyTo?.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]); // Replaces the previous IntersectionObserver-on-sentinel pattern: Virtuoso // calls `startReached` when the user scrolls near the first row of the // virtualized list. We bump `displayCount` the same way the old observer // did. Wrapped in useCallback so Virtuoso doesn't tear down its scroll // observer on every parent re-render. const handleStartReached = useCallback(() => { setDisplayCount((n) => { if (n >= messages.length) return n; return Math.min(messages.length, n * 2); }); }, [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], ); // Pre-compute quoted refs per message into a stable map. Calling // `buildQuoted(m.replyToId)` inline inside the `.map` returned a fresh // object on every parent render, defeating `React.memo` on MessageBubble. // With the map memoized on the same deps as `buildQuoted`, each bubble // gets a stable `quoted` reference until the underlying data actually // changes (new messages, sender renames, language switch). const quotedByMessage = useMemo(() => { const out = new Map(); for (const m of messages) { out.set(m.id, buildQuoted(m.replyToId)); } return out; }, [messages, buildQuoted]); // Build the discriminated-union row list Virtuoso renders. We use a // single virtualized list rather than separate "messages" and "pending" // sections so the unsent items stay at the bottom of the scroll viewport // (and Virtuoso's `followOutput` still triggers correctly when a new // outbox item is appended). Optional row 0 is the "load older" tile — // matches the old IntersectionObserver-sentinel pattern. const virtuosoRows = useMemo(() => { const out: VirtuosoRow[] = []; const hasLoader = displayCount < messages.length; if (hasLoader) { out.push({ kind: 'loader', key: '__loader__' }); } const sliceStart = Math.max(0, messages.length - displayCount); for (let i = sliceStart; i < messages.length; i++) { const m = messages[i]; if (!m) continue; out.push({ kind: 'message', key: m.id, message: m, idx: i }); } for (const p of pending) { out.push({ kind: 'pending', key: 'pending-' + p.id, item: p }); } return out; }, [messages, pending, displayCount]); // Snapshot of the saved position for this conversation, captured once on // mount. Used to derive the `initialTopMostItemIndex` we hand to the // Virtuoso instance below — Virtuoso applies that index synchronously // before its first paint, so re-entering a chat shows the saved row in // one frame rather than a "starts at top, jumps" flicker. // // Declared HERE (above `initialTopMostIndex`) rather than further down // because the useMemo that consumes it would otherwise hit a TDZ on // first render — `const` refs aren't hoisted. const savedPositionRef = useRef<{ topmostIndex: number; stickToBottom: boolean } | null>( null, ); if (savedPositionRef.current === null && id) { savedPositionRef.current = scrollPositions.get(id) ?? null; } // Initial scroll position for the freshly-mounted Virtuoso instance. // Default = bottom (newest message). If we have a saved position from a // previous visit to this chat AND the user wasn't sticking to the // bottom, restore the saved row index (clamped to the current row // count in case the cache was trimmed). const initialAnchor = useMemo<{ type: 'bottom' } | { type: 'row'; index: number }>(() => { const saved = savedPositionRef.current; if (saved && !saved.stickToBottom) return { type: 'row', index: saved.topmostIndex }; return { type: 'bottom' }; }, []); const jumpToMessage = useCallback( (targetId: string) => { const msgIdx = messages.findIndex((m) => m.id === targetId); if (msgIdx < 0) return; // pinned message outside loaded cache — no-op // Make sure the target is actually inside the rendered slice; if the // user has only loaded the most-recent 150 rows but is jumping to an // older message, expand the slice so the row exists in the virtual // list before we ask Virtuoso to scroll to it. const needsAtLeast = messages.length - msgIdx; if (needsAtLeast > displayCount) { setDisplayCount(needsAtLeast); } // Convert message-array index into row index for the virtuoso rows // array (see `rows` further down). The slice starts at // `messages.length - displayCount`, and row 0 is the optional // "load older" header. const targetDisplayCount = Math.max(displayCount, needsAtLeast); const sliceStart = Math.max(0, messages.length - targetDisplayCount); const hasLoader = targetDisplayCount < messages.length; const rowIndex = (hasLoader ? 1 : 0) + (msgIdx - sliceStart); // requestAnimationFrame: when we just bumped `displayCount`, Virtuoso // needs a paint to register the new rows before scrollToIndex can // resolve the target row. Without this, the scroll either no-ops or // lands on a stale row. requestAnimationFrame(() => { listRef.current?.scrollToRow(rowIndex, 'center', 'smooth'); }); setHighlightedId(targetId); window.setTimeout( () => setHighlightedId((cur) => (cur === targetId ? null : cur)), 1600, ); }, [messages, displayCount], ); const handleReply = useCallback((m: DecryptedMessage) => { setReplyTo(m); composerRef.current?.focus(); }, []); const handleForward = useCallback((m: DecryptedMessage) => { setForwardTarget(m); }, []); // Stable handler for MessageBubble's `onAvatarClick`. Previously this was // an inline arrow in the `.map`, which gave every row a fresh callback ref // and defeated `React.memo` on the bubble (every parent re-render — every // keystroke in the composer — re-rendered all 200 bubbles). const handleAvatarClick = useCallback((uid: string, ev: React.MouseEvent) => { ev.stopPropagation(); setProfilePopover({ userId: uid, x: ev.clientX, y: ev.clientY, }); }, []); 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]); // Scroll-to-bottom is handled by Virtuoso's `followOutput` prop, which // fires whenever the rendered row count grows and auto-scrolls down only // if the user was already at the bottom — exactly the Discord behavior // we want for both outgoing sends and incoming realtime messages. // The old useLayoutEffect that wrote `scrollTop = scrollHeight` is no // longer needed: Virtuoso owns scroll positioning now. // (savedPositionRef declared earlier — see TDZ note above the // initialTopMostIndex useMemo.) // Track whether the user is currently scrolled to the bottom. Virtuoso // calls this whenever the bottom-state changes; we feed it into // `stickToBottom` (used by the "jump to newest" pill and by the // "new messages while away" counter logic). Also clears the unread- // away counter when the user actually reaches the bottom. const handleAtBottomStateChange = useCallback( (atBottom: boolean) => { setStickToBottom(atBottom); if (atBottom) setNewMessagesWhileAway(0); if (id) { scrollPositions.set(id, { topmostIndex: topmostIndexRef.current, stickToBottom: atBottom, }); } }, [id], ); // Persists the topmost-visible row index per conversation so re-entering // the chat lands roughly where the user left off (see scrollPositions // Map). Virtuoso fires `rangeChanged` whenever the visible range shifts; // we only care about the start of the range here. const handleRangeChanged = useCallback( (range: { startIndex: number; endIndex: number }) => { topmostIndexRef.current = range.startIndex; if (id) { const prev = scrollPositions.get(id); scrollPositions.set(id, { topmostIndex: range.startIndex, stickToBottom: prev?.stickToBottom ?? true, }); } }, [id], ); const jumpToBottom = useCallback(() => { listRef.current?.scrollToBottom('smooth'); setStickToBottom(true); setNewMessagesWhileAway(0); }, []); // Whenever an outgoing pending row appears, snap the viewport to the // bottom so the user sees their freshly-sent message land. This replaces // the old `setStickToBottom(true)` pattern that piggy-backed on a // `useLayoutEffect` writing scrollTop — Virtuoso owns scroll positioning // now, so we have to call it explicitly. Tracked via a ref so we only // scroll when the count actually grew (not on every render where it // happens to be > 0). // Initialize from the current pending count rather than 0 so we don't // fire scrollToIndex(LAST) on the very first render of a chat that // already has outbox-queued items. Only growth of `pending.length` // across renders should trigger the snap-to-bottom (i.e., the user // just submitted something new). const lastPendingCountRef = useRef(pending.length); useEffect(() => { if (pending.length > lastPendingCountRef.current) { listRef.current?.scrollToBottom('auto'); } lastPendingCountRef.current = pending.length; }, [pending.length]); // Snap the viewport back to the bottom after a send. The composer // shrinks (cleared text, dismissed reply preview, dropped attachment // thumbs) which lets the Virtuoso area grow vertically — leaving the // just-sent bubble visibly above the new bottom for a frame. // `requestAnimationFrame` defers the scroll until React has committed // the composer-height change, so Virtuoso's ResizeObserver has // already seen the new viewport and `index: 'LAST', align: 'end'` // targets the correct bottom edge. const snapToBottom = useCallback(() => { window.requestAnimationFrame(() => { listRef.current?.scrollToBottom('auto'); }); }, []); 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.map((a) => a.file), replyTo?.id ?? null, { viewOnceFlags: attachments.map((a) => a.viewOnce) }, ); setText(''); setAttachments([]); setReplyTo(null); if (fileInputRef.current) fileInputRef.current.value = ''; setStickToBottom(true); notifyStopTyping(); if (id) clearDraft(id); snapToBottom(); } 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(); snapToBottom(); } catch (err: unknown) { setPollError(err instanceof Error ? err.message : 'Umfrage konnte nicht gesendet werden'); } finally { setPollSending(false); } }, [send, replyTo?.id, notifyStopTyping, snapToBottom], ); 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); snapToBottom(); } catch (err: unknown) { setSendError(err instanceof Error ? err.message : 'Whiteboard konnte nicht angelegt werden'); } finally { setCreatingWhiteboard(false); } }, [id, creatingWhiteboard, send, replyTo?.id, snapToBottom]); 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); snapToBottom(); } catch (err: unknown) { setWatchError(err instanceof Error ? err.message : 'Konnte Watch-Together nicht starten'); } finally { setWatchCreating(false); } }, [id, watchUrl, send, replyTo?.id, snapToBottom]); 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); snapToBottom(); } catch (err: unknown) { setGameError(err instanceof Error ? err.message : 'Konnte Spiel nicht starten'); } finally { setGameCreating(false); } }, [id, conversation, myId, send, replyTo?.id, snapToBottom]); async function ingestFiles(files: File[]) { const compressed = await compressImages(files); const next: PendingAttachment[] = []; for (const f of compressed) { if (f.size > 10 * 1024 * 1024) { setSendError('Datei zu groß (max 10 MB)'); continue; } // New attachments default to viewOnce=false; user opts in per-thumb // via the eye-toggle button on the preview (P7.T4). next.push({ file: f, viewOnce: false }); } 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.', })} />
) : (
{firstUnreadId && !firstUnreadJumpDismissed && ( )} {(!stickToBottom || newMessagesWhileAway > 0) && ( )} {isDraggingFile && ( )}
{sendError && (
{sendError}
)} {replyTo && (
)} {attachments.length > 0 && (
{attachments.map((a, idx) => ( setAttachments((prev) => prev.filter((_, i) => i !== idx))} {...(a.file.type.startsWith('image/') ? { onEdit: () => setAnnotatingIndex(idx), onToggleViewOnce: () => setAttachments((prev) => prev.map((x, i) => i === idx ? { ...x, viewOnce: !x.viewOnce } : x, ), ), } : {})} /> ))}
)}
{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)} /> {/* [+] popover trigger — opens ComposerActionsMenu (file/poll/whiteboard/watch/game) */} setActionsMenuOpen(false)} onAttachFile={() => fileInputRef.current?.click()} onCreatePoll={() => { setPollError(null); setPollDialogOpen(true); }} onCreateWhiteboard={() => void handleCreateWhiteboard()} onStartWatchTogether={() => setWatchDialogOpen(true)} onStartGame={() => setGameDialogOpen(true)} canStartGame={conversation?.members?.length === 2} />
{ 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); } }} />