Files
ChatApp/apps/desktop/src/pages/ConversationPage.tsx
T

1954 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, { topmostIndex: number; stickToBottom: boolean }>();
/** 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<Set<string>>(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<string>(() => {
if (!id) return '';
return getDraftSync(id)?.text ?? '';
});
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(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<PendingAttachment[]>([]);
const [annotatingIndex, setAnnotatingIndex] = useState<number | null>(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<string | null>(null);
const [creatingWhiteboard, setCreatingWhiteboard] = useState(false);
const [openWatchSessionId, setOpenWatchSessionId] = useState<string | null>(null);
const [watchDialogOpen, setWatchDialogOpen] = useState(false);
const [watchUrl, setWatchUrl] = useState('');
const [watchError, setWatchError] = useState<string | null>(null);
const [watchCreating, setWatchCreating] = useState(false);
const [openGameId, setOpenGameId] = useState<string | null>(null);
const [gameDialogOpen, setGameDialogOpen] = useState(false);
const [gameError, setGameError] = useState<string | null>(null);
const [gameCreating, setGameCreating] = useState(false);
const [pollError, setPollError] = useState<string | null>(null);
const [replyTo, setReplyTo] = useState<DecryptedMessage | null>(null);
const [forwardTarget, setForwardTarget] = useState<DecryptedMessage | null>(null);
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchIdx, setSearchIdx] = useState(0);
const [searchSenderId, setSearchSenderId] = useState<string>('');
const [searchAttachmentsOnly, setSearchAttachmentsOnly] = useState(false);
const [searchDateFrom, setSearchDateFrom] = useState<string>('');
const [searchDateTo, setSearchDateTo] = useState<string>('');
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const [displayCount, setDisplayCount] = useState<number>(150);
const [isDraggingFile, setIsDraggingFile] = useState(false);
const [firstUnreadId, setFirstUnreadId] = useState<string | null>(null);
const [firstUnreadJumpDismissed, setFirstUnreadJumpDismissed] = useState(false);
const [newMessagesWhileAway, setNewMessagesWhileAway] = useState(0);
const firstUnreadComputedRef = useRef<boolean>(false);
const previousMessageIdsRef = useRef<Set<string>>(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<HTMLButtonElement>(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<MessageListHandle>(null);
const topmostIndexRef = useRef<number>(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const composerRef = useRef<HTMLTextAreaElement>(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<string, DecryptedMessage>();
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<string, QuotedRef | null>();
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<VirtuosoRow[]>(() => {
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<DecryptedMessage[]>([]);
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<string>();
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 (
<div
className="relative flex h-full flex-col"
onDragEnter={(e) => {
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 && (
<ConversationHeader
conversation={conversation}
peerPresence={peerPresence}
onMediaClick={() => 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 && (
<SearchBar
query={searchQuery}
onQueryChange={setSearchQuery}
matches={searchMatches.length}
activeIdx={searchIdx}
senderId={searchSenderId}
onSenderChange={setSearchSenderId}
attachmentsOnly={searchAttachmentsOnly}
onAttachmentsOnlyChange={setSearchAttachmentsOnly}
dateFrom={searchDateFrom}
onDateFromChange={setSearchDateFrom}
dateTo={searchDateTo}
onDateToChange={setSearchDateTo}
members={conversation?.members ?? []}
onPrev={() =>
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. */}
<div className="flex min-h-0 flex-1 flex-row">
<div className="relative flex min-w-0 flex-1 flex-col">
{/* 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 && <CallPreviewPanel conversation={conversation} />}
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
{conversation && <InCallPanel conversation={conversation} />}
<div className="discord-chat-surface flex min-h-0 flex-1 flex-col bg-surface-3">
{loading ? (
<div className="flex items-center gap-2 px-5 py-4 text-xs text-fg-muted">
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
</div>
) : error ? (
<div className="px-5 py-4">
<Banner>{error}</Banner>
</div>
) : messages.length === 0 ? (
<div className="px-5 py-4">
<EmptyState
icon={<SendIcon className="h-8 w-8" />}
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.',
})}
/>
</div>
) : (
<MessageList
ref={listRef}
rows={virtuosoRows}
// Deferred reveal: the list stays hidden until messages + reactions
// + the unread divider are loaded, then anchors and reveals — so the
// post-paint height cascade is never visible (no chat-switch flicker).
ready={listReady}
computeKey={(row) => row.key}
initialAnchor={initialAnchor}
// 250px at-bottom tolerance — a tall appended row (image, voice note,
// grouped attachments) shouldn't push the user out of the at-bottom zone.
atBottomThreshold={250}
onReachTop={handleStartReached}
onAtBottomChange={handleAtBottomStateChange}
onTopRowChange={(topIndex) =>
handleRangeChanged({ startIndex: topIndex, endIndex: topIndex })
}
renderRow={(_index, row) => {
if (row.kind === 'loader') {
return (
<div className="flex items-center justify-center py-2 text-xs text-fg-muted">
Lade ältere Nachrichten
</div>
);
}
if (row.kind === 'pending') {
return (
<PendingBubble
item={row.item}
onRetry={() => retryPending(row.item.id)}
onCancel={() => cancelPending(row.item.id)}
/>
);
}
const m = row.message;
const idx = row.idx;
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 (
<div className="px-5">
{firstUnreadId === m.id && (
<div
aria-label="Neue Nachrichten"
className="my-2 flex items-center gap-3 px-2"
>
<span className="h-px flex-1 bg-rose-500/60" />
<span className="rounded-full bg-rose-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-rose-500">
Neue Nachrichten
</span>
<span className="h-px flex-1 bg-rose-500/60" />
</div>
)}
<MessageBubble
message={m}
mine={m.senderId === myId}
groupedWithPrev={grouped}
isLastOfRun={isLastOfRun}
senderDisplayName={senderProfile?.displayName}
senderAvatarUrl={senderProfile?.avatarUrl}
conversationId={id ?? ''}
reactions={reactionsByMessage.get(m.id) ?? EMPTY_REACTIONS}
onToggleReaction={toggleReaction}
onVotePoll={votePoll}
showSeen={m.id === lastSeenMessageId}
{...(m.senderId === myId
? {
deliveryState: computeDeliveryState({
messageId: m.id,
isGroup: !!isGroup,
recipientCount: groupRecipientCount,
peerReadSet,
peerDeliveredSet,
groupRead,
groupDelivered,
}),
}
: {})}
quoted={quotedByMessage.get(m.id) ?? null}
onJumpToMessage={jumpToMessage}
onReply={handleReply}
onForward={handleForward}
onAvatarClick={handleAvatarClick}
highlighted={highlightedId === m.id}
isPinned={pinnedIds.has(m.id)}
onTogglePin={handleTogglePin}
/>
</div>
);
}}
/>
)}
</div>
{firstUnreadId && !firstUnreadJumpDismissed && (
<button
type="button"
onClick={() => {
jumpToMessage(firstUnreadId);
setFirstUnreadJumpDismissed(true);
}}
className="absolute left-1/2 top-[76px] z-30 inline-flex -translate-x-1/2 cursor-pointer items-center gap-2 rounded-full border border-rose-500/30 bg-rose-500 px-3 py-1.5 text-xs font-semibold text-white shadow-lg transition hover:bg-rose-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-300/60"
>
<ChevronDownIcon className="h-3.5 w-3.5" />
<span>Zu ungelesen</span>
</button>
)}
{(!stickToBottom || newMessagesWhileAway > 0) && (
<button
type="button"
onClick={jumpToBottom}
className="absolute bottom-[92px] left-1/2 z-30 inline-flex -translate-x-1/2 cursor-pointer items-center gap-2 rounded-full border border-line bg-surface-2 px-3 py-1.5 text-xs font-semibold text-fg shadow-lg transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:bg-[#2b2d31] dark:hover:bg-[#383a40]"
>
<ChevronDownIcon className="h-3.5 w-3.5 text-accent" />
<span>
{newMessagesWhileAway > 0
? newMessagesWhileAway + ' neue Nachrichten'
: 'Zum neuesten'}
</span>
</button>
)}
<TypingIndicator typingUserIds={typingUserIds} members={conversation?.members ?? []} />
{isDraggingFile && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center rounded-lg border-2 border-dashed border-accent/60 bg-accent/10 backdrop-blur-sm"
>
<div className="rounded-xl border border-accent/40 bg-surface-3/90 px-4 py-3 text-sm font-semibold text-fg shadow-xl">
Datei hier ablegen zum Anhängen
</div>
</div>
)}
<form onSubmit={handleSend} className="discord-chat-surface bg-surface-3 px-5 pb-5 pt-2">
{sendError && (
<div className="mb-2">
<Banner>{sendError}</Banner>
</div>
)}
{replyTo && (
<div className="mb-2 flex items-stretch gap-2 rounded-lg border border-line bg-surface-2 p-2 text-sm dark:bg-[#2b2d31]">
<span aria-hidden="true" className="w-1 shrink-0 rounded-full bg-accent" />
<ReplyIcon className="mt-0.5 h-4 w-4 shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-semibold text-fg">
{t('app:chats.replying_to', {
name: senderNameFor(replyTo.senderId),
defaultValue: 'Antwort an ' + senderNameFor(replyTo.senderId),
})}
</p>
<p className="truncate text-xs italic text-fg-muted">
{(() => {
if (!replyTo.plaintext) return '…';
const p = parseMessagePayload(replyTo.plaintext);
if (p.kind === 'poll') return 'Umfrage: ' + p.question;
if (p.kind !== 'text') return '';
if (!p.text && p.attachments.length > 0) return '📎';
return p.text;
})()}
</p>
</div>
<button
type="button"
onClick={() => setReplyTo(null)}
aria-label={t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
className="shrink-0 cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg dark:hover:bg-[#383a40]"
>
<XIcon className="h-4 w-4" />
</button>
</div>
)}
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map((a, idx) => (
<AttachmentPreview
key={idx}
file={a.file}
viewOnce={a.viewOnce}
onRemove={() => 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,
),
),
}
: {})}
/>
))}
</div>
)}
<div className="discord-composer relative flex items-end gap-1 rounded-xl border border-transparent bg-surface-2 p-1.5 shadow-sm focus-within:border-accent/60 focus-within:ring-2 focus-within:ring-accent/20">
{isGroup && mentionState && conversation && (
<MentionAutocomplete
members={conversation.members}
query={mentionState.query}
excludeUserId={myId}
onSelect={(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);
const caret = (before + inserted).length;
requestAnimationFrame(() => {
const el = composerRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(caret, caret);
});
}}
onClose={() => setMentionState(null)}
/>
)}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => handleFilesChosen(e.target.files)}
/>
{/* [+] popover trigger — opens ComposerActionsMenu (file/poll/whiteboard/watch/game) */}
<button
ref={actionsMenuAnchorRef}
type="button"
onClick={() => setActionsMenuOpen((v) => !v)}
aria-label={t('app:composer.more_actions', { defaultValue: 'Mehr Aktionen' })}
title={t('app:composer.more_actions', { defaultValue: 'Mehr Aktionen' })}
aria-expanded={actionsMenuOpen}
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
>
<PlusIcon className="h-4 w-4" />
</button>
<ComposerActionsMenu
anchorRef={actionsMenuAnchorRef}
open={actionsMenuOpen}
onClose={() => 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}
/>
<div className="relative">
<button
type="button"
data-emoji-trigger
onClick={() => setEmojiOpen((v) => !v)}
aria-label="Emoji einfügen"
title="Emoji einfügen"
aria-expanded={emojiOpen}
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
>
<SmileIcon className="h-4 w-4" />
</button>
<EmojiPicker
open={emojiOpen}
onPick={(emoji) => {
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)}
/>
</div>
<div className="relative">
<button
type="button"
onClick={() => setGifPickerOpen((v) => !v)}
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
aria-label="GIF"
title="GIF einfügen"
>
<span className="text-xs font-bold">GIF</span>
</button>
<GifPicker
open={gifPickerOpen}
onClose={() => setGifPickerOpen(false)}
onPick={(gif) => void handleGifPick(gif)}
/>
</div>
<VoiceRecorder
disabled={sending}
onComplete={async (file) => {
try {
await send('', [file], replyTo?.id ?? null);
setReplyTo(null);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'send failed';
setSendError(msg);
}
}}
/>
<textarea
ref={composerRef}
value={text}
onChange={(e) => {
const next = e.target.value;
setText(next);
if (next.length > 0) notifyTyping();
const caret = e.target.selectionStart ?? next.length;
const before = next.slice(0, caret);
const atIdx = before.lastIndexOf('@');
if (atIdx >= 0 && (atIdx === 0 || /\s/.test(before[atIdx - 1] ?? ''))) {
const q = before.slice(atIdx + 1);
if (!/\s/.test(q)) {
setMentionState({ query: q, start: atIdx });
return;
}
}
setMentionState(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
}}
onPaste={(e) => {
const items = e.clipboardData?.items;
if (!items) return;
const pics: File[] = [];
for (const it of Array.from(items)) {
if (it.kind === 'file') {
const f = it.getAsFile();
if (f && f.type.startsWith('image/')) pics.push(f);
}
}
if (pics.length > 0) {
e.preventDefault();
void ingestFiles(pics);
}
}}
rows={1}
placeholder="Nachricht schreiben…"
className="max-h-40 min-h-[40px] flex-1 resize-none rounded-lg bg-transparent px-2 py-2.5 text-sm text-fg placeholder-fg-muted outline-none"
/>
<button
type="submit"
disabled={sending || (text.trim().length === 0 && attachments.length === 0)}
aria-busy={sending}
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:cursor-not-allowed disabled:opacity-45"
>
{sending ? <SpinnerIcon className="h-4 w-4" /> : <ArrowRightIcon className="h-4 w-4" />}
</button>
</div>
</form>
</div>
{isGroup && conversation && (
<GroupInfoPanel
open={infoPanelOpen}
onClose={() => setInfoPanelOpen(false)}
conversation={conversation}
/>
)}
<MediaFilesDrawer
open={mediaDrawerOpen}
index={attachmentIndex}
senderNameFor={senderNameFor}
onJumpToMessage={(messageId) => {
setMediaDrawerOpen(false);
jumpToMessage(messageId);
}}
onClose={() => setMediaDrawerOpen(false)}
/>
</div>
<ForwardDialog
open={forwardTarget !== null}
message={forwardTarget}
currentConversationId={id ?? null}
onClose={() => setForwardTarget(null)}
/>
<PollComposerDialog
open={pollDialogOpen}
sending={pollSending}
error={pollError}
onClose={() => {
if (!pollSending) setPollDialogOpen(false);
}}
onSubmit={handlePollSubmit}
/>
{profilePopover && (
<UserProfilePopover
userId={profilePopover.userId}
profile={
conversation?.members.find((m) => m.userId === profilePopover.userId)?.profile ??
conversation?.peer ??
null
}
x={profilePopover.x}
y={profilePopover.y}
onClose={() => setProfilePopover(null)}
/>
)}
<PinnedMessagesPanel
open={pinnedPanelOpen}
pins={pins}
onClose={() => setPinnedPanelOpen(false)}
onJump={(messageId) => {
// Close the panel first so the underlying message-list viewport
// is fully visible before the smooth-scroll runs (otherwise the
// panel would briefly cover the highlighted target row).
setPinnedPanelOpen(false);
jumpToMessage(messageId);
}}
onUnpin={(messageId) => void handleTogglePin(messageId)}
/>
{annotatingIndex !== null && attachments[annotatingIndex] && (
<Suspense fallback={null}>
<ImageAnnotator
file={attachments[annotatingIndex]!.file}
onCancel={() => setAnnotatingIndex(null)}
onSave={(next) => {
// Preserve the per-attachment viewOnce flag across annotation —
// the user's burn-after-viewing intent shouldn't reset just
// because they redrew the image.
setAttachments((prev) =>
prev.map((a, i) =>
i === annotatingIndex ? { file: next, viewOnce: a.viewOnce } : a,
),
);
setAnnotatingIndex(null);
}}
/>
</Suspense>
)}
{openWhiteboardId && (
<Suspense fallback={null}>
<WhiteboardModal
whiteboardId={openWhiteboardId}
onClose={() => setOpenWhiteboardId(null)}
/>
</Suspense>
)}
{openWatchSessionId && (
<Suspense fallback={null}>
<WatchTogetherModal
sessionId={openWatchSessionId}
onClose={() => setOpenWatchSessionId(null)}
/>
</Suspense>
)}
{openGameId && (
<Suspense fallback={null}>
<GameModal
gameId={openGameId}
onClose={() => setOpenGameId(null)}
/>
</Suspense>
)}
{gameDialogOpen && (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-[70] flex items-center justify-center bg-ink-950/90 p-6"
onClick={(e) => {
if (e.target === e.currentTarget) setGameDialogOpen(false);
}}
>
<div className="w-full max-w-sm rounded-2xl border border-line bg-surface-2 p-5 shadow-xl">
<h2 className="mb-3 font-display text-lg font-semibold text-fg">
{t('app:game.pick_title', { defaultValue: 'Spiel auswählen' })}
</h2>
{gameError && (
<p className="mb-2 text-xs text-rose-400">{gameError}</p>
)}
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => void handleStartGame('ttt')}
disabled={gameCreating}
className="flex flex-col items-center gap-2 rounded-xl border border-line bg-surface-3 p-4 text-fg transition hover:border-accent/40 hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="text-3xl">×</span>
<span className="text-xs font-semibold">Tic-Tac-Toe</span>
</button>
<button
type="button"
onClick={() => void handleStartGame('c4')}
disabled={gameCreating}
className="flex flex-col items-center gap-2 rounded-xl border border-line bg-surface-3 p-4 text-fg transition hover:border-accent/40 hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="text-3xl">🔴🟡</span>
<span className="text-xs font-semibold">Vier-Gewinnt</span>
</button>
</div>
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={() => { setGameDialogOpen(false); setGameError(null); }}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface"
>
{t('app:game.cancel', { defaultValue: 'Abbrechen' })}
</button>
</div>
</div>
</div>
)}
{watchDialogOpen && (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-[70] flex items-center justify-center bg-ink-950/90 p-6"
onClick={(e) => {
if (e.target === e.currentTarget) setWatchDialogOpen(false);
}}
>
<div className="w-full max-w-md rounded-2xl border border-line bg-surface-2 p-5 shadow-xl">
<h2 className="mb-3 font-display text-lg font-semibold text-fg">
{t('app:watch.dialog_title', { defaultValue: 'Watch Together starten' })}
</h2>
<input
type="url"
placeholder="https://youtu.be/..."
value={watchUrl}
onChange={(e) => { setWatchUrl(e.target.value); setWatchError(null); }}
className="mb-2 w-full rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
{watchError && (
<p className="mb-2 text-xs text-rose-400">{watchError}</p>
)}
<div className="mt-3 flex items-center justify-end gap-2">
<button
type="button"
onClick={() => { setWatchDialogOpen(false); setWatchUrl(''); setWatchError(null); }}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface"
>
{t('app:watch.cancel', { defaultValue: 'Abbrechen' })}
</button>
<button
type="button"
onClick={() => void handleStartWatchTogether()}
disabled={watchCreating || !watchUrl.trim()}
className="cursor-pointer rounded-md bg-accent px-4 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{watchCreating
? t('app:watch.starting', { defaultValue: 'Startet…' })
: t('app:watch.start', { defaultValue: 'Starten' })}
</button>
</div>
</div>
</div>
)}
</div>
);
}
interface SearchBarProps {
query: string;
onQueryChange: (q: string) => void;
matches: number;
activeIdx: number;
senderId: string;
onSenderChange: (id: string) => void;
attachmentsOnly: boolean;
onAttachmentsOnlyChange: (v: boolean) => void;
dateFrom: string;
onDateFromChange: (v: string) => void;
dateTo: string;
onDateToChange: (v: string) => void;
members: { userId: string; profile: { displayName?: string | null } | null }[];
onPrev: () => void;
onNext: () => void;
onClose: () => void;
}
function SearchBar({
query,
onQueryChange,
matches,
activeIdx,
senderId,
onSenderChange,
attachmentsOnly,
onAttachmentsOnlyChange,
dateFrom,
onDateFromChange,
dateTo,
onDateToChange,
members,
onPrev,
onNext,
onClose,
}: SearchBarProps) {
const { t } = useTranslation(['app']);
return (
<div className="discord-chat-panel flex flex-col gap-2 border-b border-line bg-surface-2 px-4 py-2">
<div className="flex items-center gap-2">
<SearchIcon className="h-4 w-4 shrink-0 text-fg-muted" />
<input
type="search"
autoFocus
value={query}
onChange={(e) => onQueryChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Enter') {
e.preventDefault();
if (e.shiftKey) onPrev();
else onNext();
}
}}
placeholder={t('app:chats.search_in_conv', { defaultValue: 'In Unterhaltung suchen…' })}
className="min-w-0 flex-1 bg-transparent text-sm text-fg placeholder-fg-muted outline-none"
/>
<span className="shrink-0 text-xs tabular-nums text-fg-muted">
{matches === 0
? query.trim().length > 0
? t('app:chats.search_none', { defaultValue: 'Keine Treffer' })
: ''
: activeIdx + 1 + ' / ' + matches}
</span>
<button
type="button"
onClick={onPrev}
disabled={matches === 0}
aria-label="Previous"
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-[#383a40]"
>
<ChevronUpIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={onNext}
disabled={matches === 0}
aria-label="Next"
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-[#383a40]"
>
<ChevronDownIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg dark:hover:bg-[#383a40]"
>
<XIcon className="h-4 w-4" />
</button>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-fg-muted">
<select
value={senderId}
onChange={(e) => onSenderChange(e.target.value)}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent dark:bg-[#383a40]"
>
<option value="">Alle Sender</option>
{members.map((m) => (
<option key={m.userId} value={m.userId}>
{m.profile?.displayName ?? m.userId.slice(0, 6)}
</option>
))}
</select>
<label className="flex cursor-pointer items-center gap-1 rounded-md border border-line bg-surface-3 px-2 py-1 text-fg dark:bg-[#383a40]">
<input
type="checkbox"
checked={attachmentsOnly}
onChange={(e) => onAttachmentsOnlyChange(e.target.checked)}
className="accent-accent"
/>
<span>Nur Anhänge</span>
</label>
<label className="flex items-center gap-1">
<span>Von</span>
<input
type="date"
value={dateFrom}
onChange={(e) => onDateFromChange(e.target.value)}
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent dark:bg-[#383a40]"
/>
</label>
<label className="flex items-center gap-1">
<span>Bis</span>
<input
type="date"
value={dateTo}
onChange={(e) => onDateToChange(e.target.value)}
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent dark:bg-[#383a40]"
/>
</label>
</div>
</div>
);
}
function computeDeliveryState(args: {
messageId: string;
isGroup: boolean;
recipientCount: number;
peerReadSet: Set<string>;
peerDeliveredSet: Set<string>;
groupRead: Map<string, Set<string>>;
groupDelivered: Map<string, Set<string>>;
}): 'sent' | 'delivered' | 'read' {
if (!args.isGroup) {
if (args.peerReadSet.has(args.messageId)) return 'read';
if (args.peerDeliveredSet.has(args.messageId)) return 'delivered';
return 'sent';
}
if (args.recipientCount === 0) return 'sent';
const reads = args.groupRead.get(args.messageId);
if (reads && reads.size >= args.recipientCount) return 'read';
const delivered = args.groupDelivered.get(args.messageId);
if (delivered && delivered.size >= args.recipientCount) return 'delivered';
return 'sent';
}
function PendingBubble({
item,
onRetry,
onCancel,
}: {
item: OutboxItem;
onRetry: () => void;
onCancel: () => void;
}) {
const failed = item.attempts >= 8;
return (
<div className="flex justify-end py-0.5">
<div
className={
'max-w-[72%] rounded-[18px] px-3.5 py-2 text-sm ' +
(failed
? 'border border-rose-500/40 bg-rose-500/10 text-rose-700 dark:text-rose-100'
: 'border border-dashed border-accent/50 bg-accent/10 text-fg opacity-80')
}
>
<p className="whitespace-pre-wrap break-words">{item.text}</p>
<div className="mt-1 flex items-center justify-end gap-2 text-[10px] uppercase tracking-wider text-fg-muted">
{failed ? (
<>
<span>{item.lastError ?? 'Senden fehlgeschlagen'}</span>
<button
type="button"
onClick={onRetry}
className="cursor-pointer rounded px-1.5 py-0.5 font-semibold text-accent hover:underline"
>
Erneut
</button>
<button
type="button"
onClick={onCancel}
className="cursor-pointer rounded px-1.5 py-0.5 font-semibold text-rose-600 hover:underline dark:text-rose-300"
>
Verwerfen
</button>
</>
) : (
<>
<SpinnerIcon className="h-3 w-3 animate-spin" />
<span>{item.attempts === 0 ? 'Senden…' : 'Wiederhole (' + item.attempts + ')'}</span>
</>
)}
</div>
</div>
</div>
);
}
function Banner({ children }: { children: React.ReactNode }) {
return (
<div
role="alert"
className="flex items-start gap-3 rounded-lg border border-rose-500/30 bg-rose-500/10 p-3 text-sm text-rose-700 dark:text-rose-100"
>
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-500 dark:text-rose-400" />
<p className="min-w-0 flex-1 break-words">{children}</p>
</div>
);
}
function AttachmentPreview({
file,
viewOnce,
onRemove,
onEdit,
onToggleViewOnce,
}: {
file: File;
viewOnce: boolean;
onRemove: () => void;
onEdit?: () => void;
onToggleViewOnce?: () => void;
}) {
const { t } = useTranslation(['app']);
const isImage = file.type.startsWith('image/');
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
if (!isImage) return;
const u = URL.createObjectURL(file);
setUrl(u);
return () => URL.revokeObjectURL(u);
}, [file, isImage]);
return (
<div className="group relative overflow-hidden rounded-lg border border-line bg-surface-2 dark:bg-[#2b2d31]">
{isImage && url ? (
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
) : (
<div className="flex h-20 w-32 flex-col justify-center gap-0.5 px-2 text-[10px]">
<span className="truncate font-semibold text-fg" title={file.name}>
{file.name || 'Datei'}
</span>
<span className="text-fg-muted">{file.type || 'unbekannt'}</span>
<span className="text-fg-muted">{(file.size / 1024).toFixed(0)} KB</span>
</div>
)}
{isImage && onEdit && (
<button
type="button"
onClick={onEdit}
aria-label="Bearbeiten"
title="Bearbeiten"
className="absolute bottom-1 left-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/70 text-white opacity-0 transition group-hover:opacity-100 hover:bg-accent/80"
>
<PencilIcon className="h-3 w-3" />
</button>
)}
{isImage && onToggleViewOnce && (
<button
type="button"
onClick={onToggleViewOnce}
aria-label={
viewOnce
? t('app:composer.view_once_off', { defaultValue: 'Einmal-Ansicht deaktivieren' })
: t('app:composer.view_once_on', { defaultValue: 'Einmal-Ansicht aktivieren' })
}
title={
viewOnce
? t('app:composer.view_once_on_hint', {
defaultValue: 'Empfänger sieht das Bild nur einmal',
})
: t('app:composer.view_once_off_hint', { defaultValue: 'Einmal-Ansicht ein/aus' })
}
className={
'absolute bottom-1 right-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full transition ' +
(viewOnce
? 'bg-accent text-accent-fg opacity-100'
: 'bg-black/70 text-white opacity-0 hover:bg-accent/80 group-hover:opacity-100')
}
>
{viewOnce ? <EyeIcon className="h-3 w-3" /> : <EyeOffIcon className="h-3 w-3" />}
</button>
)}
{/* When viewOnce is on, overlay a persistent "1×" badge so the user
has visual confirmation independent of the small toggle button. */}
{isImage && viewOnce && (
<div
aria-hidden="true"
className="pointer-events-none absolute bottom-1 right-7 rounded-md bg-accent/90 px-1 py-0.5 text-[9px] font-bold text-accent-fg"
>
1×
</div>
)}
<button
type="button"
onClick={onRemove}
aria-label="Entfernen"
className="absolute right-1 top-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/70 text-white transition hover:bg-rose-500/80"
>
<XIcon className="h-3 w-3" />
</button>
</div>
);
}