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

1368 lines
52 KiB
TypeScript

import { parseMessagePayload, pinMessage, unpinMessage } from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { ConversationHeader } from '../components/ConversationHeader';
import { EmojiPicker } from '../components/EmojiPicker';
import { EmptyState } from '../components/EmptyState';
import { ForwardDialog } from '../components/ForwardDialog';
import { GroupInfoPanel } from '../components/GroupInfoPanel';
import {
AlertIcon,
ArrowRightIcon,
ChevronDownIcon,
ChevronUpIcon,
PlusIcon,
PollIcon,
ReplyIcon,
SearchIcon,
SendIcon,
SmileIcon,
SpinnerIcon,
XIcon,
} from '../components/icons';
import { InCallPanel } from '../components/InCallPanel';
import { IncomingCallPanel } from '../components/IncomingCallPanel';
import { CallPreviewPanel } from '../components/CallPreviewPanel';
import { MediaFilesDrawer } from '../components/MediaFilesDrawer';
import { MentionAutocomplete } from '../components/MentionAutocomplete';
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
import { PinnedMessagesPanel } from '../components/PinnedMessagesPanel';
import { PollComposerDialog } from '../components/PollComposerDialog';
import { UserProfilePopover } from '../components/UserProfilePopover';
import { TypingIndicator } from '../components/TypingIndicator';
import { VoiceRecorder } from '../components/VoiceRecorder';
import type { DecryptedMessage } from '@chat-app/shared/chat';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useConversationsContext } from '../context/ConversationsContext';
import { collectConversationAttachments, createPollPayload } from '../lib/conversationFeatures';
import { compressImages } from '../lib/imageCompress';
import { ensureInstallId } from '../lib/installId';
import { searchCachedMessages } from '../lib/messageCache';
import { supabase } from '../lib/supabase';
import type { OutboxItem } from '../lib/messageOutbox';
import { useConversationMessages } from '../lib/useConversationMessages';
import { useMessageReactions } from '../lib/useMessageReactions';
import { useGroupReceipts } from '../lib/useGroupReceipts';
import { markDelivered, useMessageDeliveries } from '../lib/useMessageDeliveries';
import { markRead as markMessagesReadRemote, useMessageReads } from '../lib/useMessageReads';
import { usePeerPresence } from '../lib/usePeerPresence';
import { usePinnedMessages } from '../lib/usePinnedMessages';
import { useTypingChannel } from '../lib/useTypingChannel';
const STICK_THRESHOLD = 80;
// Per-conversation scroll memory. Module-scoped so it survives re-mounts
// of ConversationPage when the route param (`id`) changes — switching
// chats unmounts/remounts the page in our router setup. Session-only
// (lost on reload, like Discord). The `stickToBottom` flag is preserved
// alongside the pixel offset so a chat the user left at the bottom keeps
// auto-following new messages when they return; a chat scrolled up
// returns to the exact spot the user was reading.
const scrollPositions = new Map<string, { scrollTop: number; stickToBottom: 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,
} = useMessageReactions(messageIds, session?.user.id);
const myId = session?.user.id;
const ownMessageIds = useMemo(
() => messages.filter((m) => m.senderId === myId).map((m) => m.id),
[messages, myId],
);
const { peerReadSet } = useMessageReads(ownMessageIds, peerId);
const { peerDeliveredSet } = useMessageDeliveries(ownMessageIds, peerId);
const isGroup = conversation?.type === 'group';
const { deliveredByMessage: groupDelivered, readByMessage: groupRead } = useGroupReceipts(
ownMessageIds,
myId,
!!isGroup,
);
const groupRecipientCount = useMemo(() => {
if (!isGroup || !conversation) return 0;
return conversation.members.filter((m) => m.userId !== myId).length;
}, [isGroup, conversation, myId]);
const deliveredTrackedRef = useRef<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('');
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const [stickToBottom, setStickToBottom] = useState(true);
const [attachments, setAttachments] = useState<File[]>([]);
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
const [mediaDrawerOpen, setMediaDrawerOpen] = useState(false);
const [pollDialogOpen, setPollDialogOpen] = useState(false);
const [pollSending, setPollSending] = 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 pins = usePinnedMessages(id);
const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false);
const pinnedIds = useMemo(() => new Set(pins.map((p) => p.messageId)), [pins]);
const handleTogglePin = useCallback(
async (messageId: string) => {
if (!id || !myId) return;
try {
if (pinnedIds.has(messageId)) {
await unpinMessage(supabase, id, messageId);
} else {
await pinMessage(supabase, id, messageId, myId);
}
} catch (err) {
console.warn('pin toggle failed', err);
}
},
[id, myId, pinnedIds],
);
const scrollRef = useRef<HTMLDivElement>(null);
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const composerRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
setReplyTo(null);
setForwardTarget(null);
setSearchOpen(false);
setMediaDrawerOpen(false);
setPollDialogOpen(false);
setSearchQuery('');
setDisplayCount(150);
setFirstUnreadId(null);
setFirstUnreadJumpDismissed(false);
setNewMessagesWhileAway(0);
previousMessageIdsRef.current = new Set();
firstUnreadComputedRef.current = false;
}, [id]);
useEffect(() => {
if (firstUnreadComputedRef.current) return;
if (!id || messages.length === 0) return;
const count = unread[id] ?? 0;
firstUnreadComputedRef.current = true;
if (count === 0 || count > messages.length) {
setFirstUnreadId(null);
return;
}
const boundary = messages[messages.length - count];
setFirstUnreadId(boundary ? boundary.id : null);
}, [id, messages, unread]);
useEffect(() => {
const previous = previousMessageIdsRef.current;
if (previous.size > 0 && !stickToBottom) {
const addedIncoming = messages.filter(
(message) => !previous.has(message.id) && message.senderId !== myId,
).length;
if (addedIncoming > 0) {
setNewMessagesWhileAway((count) => count + addedIncoming);
}
}
previousMessageIdsRef.current = new Set(messages.map((message) => message.id));
}, [messages, myId, stickToBottom]);
useEffect(() => {
const el = loadMoreSentinelRef.current;
if (!el) return;
if (displayCount >= messages.length) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
setDisplayCount((n) => Math.min(messages.length, n * 2));
}
},
{ root: scrollRef.current, rootMargin: '200px 0px' },
);
observer.observe(el);
return () => observer.disconnect();
}, [displayCount, messages.length]);
const messageById = useMemo(() => {
const m = new Map<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],
);
const jumpToMessage = useCallback((targetId: string) => {
const el = scrollRef.current?.querySelector<HTMLElement>(
'[data-message-id="' + CSS.escape(targetId) + '"]',
);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
setHighlightedId(targetId);
window.setTimeout(() => setHighlightedId((cur) => (cur === targetId ? null : cur)), 1600);
}, []);
const handleReply = useCallback((m: DecryptedMessage) => {
setReplyTo(m);
composerRef.current?.focus();
}, []);
const handleForward = useCallback((m: DecryptedMessage) => {
setForwardTarget(m);
}, []);
const searchActive = useMemo(
() =>
searchQuery.trim().length > 0 ||
searchSenderId !== '' ||
searchAttachmentsOnly ||
searchDateFrom !== '' ||
searchDateTo !== '',
[searchQuery, searchSenderId, searchAttachmentsOnly, searchDateFrom, searchDateTo],
);
const [ftsExtras, setFtsExtras] = useState<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(() => {
if (id && messages.length > 0) markRead(id);
}, [id, messages.length, markRead]);
// useLayoutEffect: run synchronously after DOM commit, before the
// browser paints. Using useEffect here let one frame of "scrollTop = 0
// (top of list)" paint between message-list mount and the auto-scroll,
// which is exactly the "flickers to a different position, then jumps"
// glitch users saw when re-entering a chat. Layout-effect fires while
// the message list is in the DOM but before paint, so the first frame
// already shows the correct scroll position.
useLayoutEffect(() => {
const el = scrollRef.current;
if (!el || !stickToBottom) return;
el.scrollTop = el.scrollHeight;
}, [messages.length, stickToBottom]);
// Restore saved scroll position once the conversation's messages have
// actually rendered. The earlier version fired on `[id]` alone and ran
// before the message list populated — scrollHeight was still tiny, so
// `el.scrollTop = saved.scrollTop` got clamped to 0 by the browser
// and the user landed at the top instead of the saved position. By
// waiting for `messages.length > 0` we know the rendered scrollHeight
// is meaningful. `restoredForRef` ensures the restore runs at most
// once per chat switch (subsequent message arrivals don't re-trigger).
const restoredForRef = useRef<string | null>(null);
const isRestoringRef = useRef(false);
// useLayoutEffect, same reason as above: writing scrollTop here happens
// before the first paint of the freshly-mounted chat, so the user
// doesn't see a frame at scrollTop=0 before the jump to the saved
// position. Combined with the messages.length gate this means the
// re-entry shows the message list AT the saved scroll location in one
// single paint — no "loaded then jumped" effect.
useLayoutEffect(() => {
const el = scrollRef.current;
if (!el || !id) return;
if (restoredForRef.current === id) return;
// Wait for the conversation's messages to populate; for a chat that
// truly has zero messages the bottom and the top are the same anyway.
if (messages.length === 0) return;
restoredForRef.current = id;
const saved = scrollPositions.get(id);
// Suppress handleScroll's persistence during the programmatic scroll
// below — otherwise the browser's clamp/normalisation could write a
// different scrollTop back into the Map and lose the saved position.
isRestoringRef.current = true;
if (saved && !saved.stickToBottom) {
el.scrollTop = saved.scrollTop;
setStickToBottom(false);
} else {
setStickToBottom(true);
el.scrollTop = el.scrollHeight;
}
requestAnimationFrame(() => {
isRestoringRef.current = false;
});
}, [id, messages.length]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
const nextStick = distanceFromBottom < STICK_THRESHOLD;
setStickToBottom(nextStick);
if (nextStick) setNewMessagesWhileAway(0);
// Persist position per chat so re-entering this conversation lands
// where the user left off (see scrollPositions module-level Map).
// Skipped during the in-flight restore so we don't immediately
// overwrite the saved position with a clamped value.
if (id && !isRestoringRef.current) {
scrollPositions.set(id, { scrollTop: el.scrollTop, stickToBottom: nextStick });
}
}, [id]);
const jumpToBottom = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
setStickToBottom(true);
setNewMessagesWhileAway(0);
}, []);
async function handleSend(e?: React.FormEvent) {
e?.preventDefault();
if ((!text.trim() && attachments.length === 0) || sending) return;
setSending(true);
setSendError(null);
try {
await send(text, attachments, replyTo?.id ?? null);
setText('');
setAttachments([]);
setReplyTo(null);
if (fileInputRef.current) fileInputRef.current.value = '';
setStickToBottom(true);
notifyStopTyping();
} catch (err: unknown) {
const code = extractErrorCode(err);
setSendError(
code
? t('errors:' + code, { defaultValue: t('errors:generic') })
: err instanceof Error
? err.message
: t('errors:generic'),
);
} finally {
setSending(false);
}
}
const handlePollSubmit = useCallback(
async (question: string, options: string[]) => {
setPollSending(true);
setPollError(null);
try {
const payload = createPollPayload(question, options);
await send(payload, [], replyTo?.id ?? null);
setPollDialogOpen(false);
setReplyTo(null);
setStickToBottom(true);
notifyStopTyping();
} catch (err: unknown) {
setPollError(err instanceof Error ? err.message : 'Umfrage konnte nicht gesendet werden');
} finally {
setPollSending(false);
}
},
[send, replyTo?.id, notifyStopTyping],
);
async function ingestFiles(files: File[]) {
const compressed = await compressImages(files);
const next: File[] = [];
for (const f of compressed) {
if (f.size > 10 * 1024 * 1024) {
setSendError('Datei zu groß (max 10 MB)');
continue;
}
next.push(f);
}
setAttachments((prev) => [...prev, ...next].slice(0, 4));
}
function handleFilesChosen(list: FileList | null) {
if (!list) return;
void ingestFiles(Array.from(list));
}
const { state: callState } = useCall();
const callHereActive =
(callState.kind === 'connected' ||
callState.kind === 'connecting' ||
callState.kind === 'outgoing') &&
callState.conversationId === id;
const incomingHere = callState.kind === 'incoming' && callState.conversationId === id;
return (
<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
ref={scrollRef}
onScroll={handleScroll}
className="discord-chat-surface flex-1 overflow-y-auto bg-surface-3 px-5 py-4"
>
{loading ? (
<div className="flex items-center gap-2 text-xs text-fg-muted">
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
</div>
) : error ? (
<Banner>{error}</Banner>
) : messages.length === 0 ? (
<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.',
})}
/>
) : (
<ul className="space-y-0.5">
{displayCount < messages.length && (
<li>
<div
ref={loadMoreSentinelRef}
className="flex items-center justify-center py-2 text-xs text-fg-muted"
>
Lade ältere Nachrichten
</div>
</li>
)}
{messages.slice(Math.max(0, messages.length - displayCount)).map((m, sliceIdx) => {
const idx = Math.max(0, messages.length - displayCount) + sliceIdx;
const prevRaw = messages[idx - 1];
const nextRaw = messages[idx + 1];
const prevIsCallEvent =
!!prevRaw && parseMessagePayload(prevRaw.plaintext).kind === 'call_event';
const nextIsCallEvent =
!!nextRaw && parseMessagePayload(nextRaw.plaintext).kind === 'call_event';
const grouped = !!prevRaw && prevRaw.senderId === m.senderId && !prevIsCallEvent;
const isLastOfRun = !nextRaw || nextRaw.senderId !== m.senderId || nextIsCallEvent;
const memberProfile =
conversation?.members.find((mm) => mm.userId === m.senderId)?.profile ?? null;
const senderProfile =
memberProfile ?? (m.senderId !== myId ? (conversation?.peer ?? null) : null);
return (
<li key={m.id}>
{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) ?? []}
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)}
onVotePoll={(emoji, optionEmojis) => votePoll(m.id, emoji, optionEmojis)}
showSeen={m.id === lastSeenMessageId}
{...(m.senderId === myId
? {
deliveryState: computeDeliveryState({
messageId: m.id,
isGroup: !!isGroup,
recipientCount: groupRecipientCount,
peerReadSet,
peerDeliveredSet,
groupRead,
groupDelivered,
}),
}
: {})}
quoted={buildQuoted(m.replyToId)}
onJumpToMessage={jumpToMessage}
onReply={handleReply}
onForward={handleForward}
onAvatarClick={(uid, ev) => {
ev.stopPropagation();
setProfilePopover({
userId: uid,
x: ev.clientX,
y: ev.clientY,
});
}}
highlighted={highlightedId === m.id}
isPinned={pinnedIds.has(m.id)}
onTogglePin={handleTogglePin}
/>
</li>
);
})}
{pending.map((p) => (
<li key={p.id}>
<PendingBubble
item={p}
onRetry={() => retryPending(p.id)}
onCancel={() => cancelPending(p.id)}
/>
</li>
))}
</ul>
)}
</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((file, idx) => (
<AttachmentPreview
key={idx}
file={file}
onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
/>
))}
</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)}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
aria-label="Datei anhängen"
title="Datei anhängen"
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>
<button
type="button"
onClick={() => {
setPollError(null);
setPollDialogOpen(true);
}}
aria-label="Umfrage erstellen"
title="Umfrage erstellen"
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]"
>
<PollIcon className="h-4 w-4" />
</button>
<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>
<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) => {
// Future: scroll to message. For now just close the panel.
setPinnedPanelOpen(false);
}}
onUnpin={(messageId) => void handleTogglePin(messageId)}
/>
</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, onRemove }: { file: File; onRemove: () => void }) {
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="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>
)}
<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>
);
}