1954 lines
76 KiB
TypeScript
1954 lines
76 KiB
TypeScript
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>
|
||
);
|
||
}
|
||
|