feat(desktop): port v0.11.4-v0.15.2 from Tauri to Electron + Discord-parity audio (v0.16.0)

Chronological port of every Tauri release commit (v0.11.4 -> v0.15.2)
into the Electron rebuild, plus Discord-style audio handling that goes
beyond the Tauri original.

Highlights:
  - All 17 Tauri release commits ported (audio fixes, custom notification
    sound, Discord-style chat UX, profile banner, changelog page,
    Discord-parity call UX, screen-share echo + re-watch UX, audio-loop
    fix).
  - Native napi-rs audio-loopback addon with WASAPI process-loopback:
      * EXCLUDE_TARGET_PROCESS_TREE for full-screen shares -> peers
        never hear themselves echoed back through the capture.
      * INCLUDE_TARGET_PROCESS_TREE for window shares -> only the
        picked window's audio is captured, not the whole OS mixer
        (Discord parity).
      * HWND -> PID resolution via Win32 GetWindowThreadProcessId.
  - Discord-style screen-source picker (thumbnail grid, screens vs
    apps tabs, live-refreshing thumbnails).
  - Hash routing fix for packaged builds (file:// can't resolve
    BrowserRouter paths).
  - Tauri sources removed (apps/desktop/src-tauri).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-05-06 23:35:01 +02:00
parent 72d02e385f
commit 825160ee46
504 changed files with 14217 additions and 14366 deletions
+212 -135
View File
@@ -14,6 +14,7 @@ import {
ChevronDownIcon,
ChevronUpIcon,
PlusIcon,
PollIcon,
ReplyIcon,
SearchIcon,
SmileIcon,
@@ -22,8 +23,11 @@ import {
} from '../components/icons';
import { InCallPanel } from '../components/InCallPanel';
import { IncomingCallPanel } from '../components/IncomingCallPanel';
import { VoiceChannelRail } from '../components/VoiceChannelRail';
import { MediaFilesDrawer } from '../components/MediaFilesDrawer';
import { MentionAutocomplete } from '../components/MentionAutocomplete';
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
import { PollComposerDialog } from '../components/PollComposerDialog';
import { UserProfilePopover } from '../components/UserProfilePopover';
import { TypingIndicator } from '../components/TypingIndicator';
import { VoiceRecorder } from '../components/VoiceRecorder';
@@ -31,6 +35,7 @@ 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 { searchCachedMessages } from '../lib/messageCache';
import type { OutboxItem } from '../lib/messageOutbox';
@@ -64,14 +69,14 @@ export function ConversationPage() {
deviceId: device?.id,
});
const messageIds = useMemo(() => messages.map((m) => m.id), [messages]);
const { byMessage: reactionsByMessage, toggle: toggleReaction } = useMessageReactions(
messageIds,
session?.user.id,
);
const {
byMessage: reactionsByMessage,
toggle: toggleReaction,
voteExclusive: votePoll,
} = useMessageReactions(messageIds, session?.user.id);
const myId = session?.user.id;
// Peer read tracking — only for 1:1 DMs.
const ownMessageIds = useMemo(
() => messages.filter((m) => m.senderId === myId).map((m) => m.id),
[messages, myId],
@@ -79,18 +84,17 @@ export function ConversationPage() {
const { peerReadSet } = useMessageReads(ownMessageIds, peerId);
const { peerDeliveredSet } = useMessageDeliveries(ownMessageIds, peerId);
// Group receipts: only meaningful when conversation is a group. We feed it
// ownMessageIds since we only render delivery state on the sender side.
const isGroup = conversation?.type === 'group';
const { deliveredByMessage: groupDelivered, readByMessage: groupRead } =
useGroupReceipts(ownMessageIds, myId, !!isGroup);
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]);
// Mark every peer-authored message as delivered on our side. Idempotent,
// so rerunning for already-acknowledged ids is a no-op server-side.
const deliveredTrackedRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!myId || messages.length === 0) return;
@@ -116,11 +120,8 @@ export function ConversationPage() {
return null;
}, [messages, peerReadSet, myId]);
// Typing channel.
const { typingUserIds, notifyTyping, notifyStopTyping } = useTypingChannel(id, myId);
// Mark incoming messages as read (server-side, visible to peer if both sides
// have receipts on). Runs whenever new messages arrive or id changes.
useEffect(() => {
if (!id || messages.length === 0 || !myId) return;
const incoming = messages.filter((m) => m.senderId !== myId).map((m) => m.id);
@@ -136,6 +137,10 @@ export function ConversationPage() {
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);
@@ -148,54 +153,64 @@ export function ConversationPage() {
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const [displayCount, setDisplayCount] = useState<number>(150);
const [isDraggingFile, setIsDraggingFile] = useState(false);
// Snapshot of the "first-unread-message" id captured once the very first
// render of this conversation lands. Stays fixed until the user switches
// away so the divider doesn't jump around while new messages arrive.
const firstUnreadRef = useRef<string | null>(null);
const [firstUnreadId, setFirstUnreadId] = useState<string | null>(null);
const [firstUnreadJumpDismissed, setFirstUnreadJumpDismissed] = useState(false);
const [newMessagesWhileAway, setNewMessagesWhileAway] = useState(0);
const firstUnreadComputedRef = useRef<boolean>(false);
const [profilePopover, setProfilePopover] = useState<
{ userId: string; x: number; y: number } | null
>(null);
const [mentionState, setMentionState] = useState<
{ query: string; start: number } | null
>(null);
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 scrollRef = useRef<HTMLDivElement>(null);
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const composerRef = useRef<HTMLTextAreaElement>(null);
// Drop reply-to / clear search state when switching conversation.
useEffect(() => {
setReplyTo(null);
setForwardTarget(null);
setSearchOpen(false);
setMediaDrawerOpen(false);
setPollDialogOpen(false);
setSearchQuery('');
setDisplayCount(150);
firstUnreadRef.current = null;
setFirstUnreadId(null);
setFirstUnreadJumpDismissed(false);
setNewMessagesWhileAway(0);
previousMessageIdsRef.current = new Set();
firstUnreadComputedRef.current = false;
}, [id]);
// On first message-list populate for this conversation, pin the divider
// above the oldest-unread message. We only compute once — subsequent
// inserts push the divider "further back" visually, which matches
// Discord's behaviour.
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) {
firstUnreadRef.current = null;
setFirstUnreadId(null);
return;
}
const boundary = messages[messages.length - count];
firstUnreadRef.current = boundary ? boundary.id : null;
setFirstUnreadId(boundary ? boundary.id : null);
}, [id, messages, unread]);
// Expand window when the "load older" sentinel scrolls into view. Doubles
// effective window on each trigger so scrolling up quickly converges to
// rendering everything.
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;
@@ -218,12 +233,14 @@ export function ConversationPage() {
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);
(senderId !== myId ? (conversation?.peer ?? null) : null);
return profile?.displayName ?? '?';
},
[conversation, myId, t],
@@ -243,7 +260,12 @@ export function ConversationPage() {
};
}
const parsed = parseMessagePayload(target.plaintext);
const text = parsed.kind === 'text' ? parsed.text : '';
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,
@@ -275,9 +297,6 @@ export function ConversationPage() {
setForwardTarget(m);
}, []);
// Search matches: messages matching query + filters. Empty query is allowed
// when filters are active, so users can e.g. show "all attachments from
// alice in the last week" without a text query.
const searchActive = useMemo(
() =>
searchQuery.trim().length > 0 ||
@@ -288,10 +307,6 @@ export function ConversationPage() {
[searchQuery, searchSenderId, searchAttachmentsOnly, searchDateFrom, searchDateTo],
);
// FTS5-backed supplementary results: covers cached messages that aren't in
// the currently-loaded window (`messages`). Runs only when there's a text
// query — filters alone stay in-memory because they depend on already-
// decrypted payload state.
const [ftsExtras, setFtsExtras] = useState<DecryptedMessage[]>([]);
useEffect(() => {
if (!id) {
@@ -317,12 +332,7 @@ export function ConversationPage() {
if (!searchActive) return [] as DecryptedMessage[];
const q = searchQuery.trim().toLowerCase();
const fromTs = searchDateFrom ? new Date(searchDateFrom).getTime() : null;
// Date inputs cover whole days — bump 'to' to end-of-day.
const toTs = searchDateTo
? new Date(searchDateTo).getTime() + 24 * 3600 * 1000 - 1
: null;
// Union the live `messages` array with any FTS5-only rows not yet
// loaded into memory, keyed by id so we don't double-count.
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) {
@@ -337,9 +347,7 @@ export function ConversationPage() {
pool.push(m);
}
}
pool.sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
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();
@@ -363,7 +371,6 @@ export function ConversationPage() {
searchDateTo,
]);
// Reset/clamp the active match index when the match set changes.
useEffect(() => {
if (searchMatches.length === 0) {
setSearchIdx(0);
@@ -372,7 +379,6 @@ export function ConversationPage() {
setSearchIdx((cur) => Math.min(cur, searchMatches.length - 1));
}, [searchMatches.length]);
// Auto-jump to current match.
useEffect(() => {
if (!searchOpen || searchMatches.length === 0) return;
const target = searchMatches[searchIdx];
@@ -407,7 +413,17 @@ export function ConversationPage() {
const el = scrollRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
setStickToBottom(distanceFromBottom < STICK_THRESHOLD);
const nextStick = distanceFromBottom < STICK_THRESHOLD;
setStickToBottom(nextStick);
if (nextStick) setNewMessagesWhileAway(0);
}, []);
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) {
@@ -437,10 +453,27 @@ export function ConversationPage() {
}
}
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[]) {
// Pre-compression so heavy phone photos (typically 4-8MB) don't bust the
// 10MB limit and don't waste storage/bandwidth. Non-image + animated
// files are passed through unchanged.
const compressed = await compressImages(files);
const next: File[] = [];
for (const f of compressed) {
@@ -459,16 +492,12 @@ export function ConversationPage() {
}
const { state: callState } = useCall();
// Hide the chat header while this conversation hosts an active call — the
// call topbar inside the dock already shows the channel name + duration,
// and fullscreen cinema needs the whole slot.
const callHereActive =
(callState.kind === 'connected' ||
callState.kind === 'connecting' ||
callState.kind === 'outgoing') &&
callState.conversationId === id;
const incomingHere =
callState.kind === 'incoming' && callState.conversationId === id;
const incomingHere = callState.kind === 'incoming' && callState.conversationId === id;
return (
<div
@@ -486,7 +515,6 @@ export function ConversationPage() {
}
}}
onDragLeave={(e) => {
// leave fires on child enter too; only clear when leaving the page container.
if (e.currentTarget === e.target) setIsDraggingFile(false);
}}
onDrop={(e) => {
@@ -500,6 +528,19 @@ export function ConversationPage() {
<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) } : {})}
/>
@@ -522,11 +563,15 @@ export function ConversationPage() {
members={conversation?.members ?? []}
onPrev={() =>
setSearchIdx((cur) =>
searchMatches.length === 0 ? 0 : (cur - 1 + searchMatches.length) % searchMatches.length,
searchMatches.length === 0
? 0
: (cur - 1 + searchMatches.length) % searchMatches.length,
)
}
onNext={() =>
setSearchIdx((cur) => (searchMatches.length === 0 ? 0 : (cur + 1) % searchMatches.length))
setSearchIdx((cur) =>
searchMatches.length === 0 ? 0 : (cur + 1) % searchMatches.length,
)
}
onClose={() => {
setSearchOpen(false);
@@ -547,10 +592,29 @@ export function ConversationPage() {
/>
)}
<MediaFilesDrawer
open={mediaDrawerOpen}
index={attachmentIndex}
senderNameFor={senderNameFor}
onJumpToMessage={(messageId) => {
setMediaDrawerOpen(false);
jumpToMessage(messageId);
}}
onClose={() => setMediaDrawerOpen(false)}
/>
{/* 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 && <VoiceChannelRail conversation={conversation} />}
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
{conversation && <InCallPanel conversation={conversation} />}
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto bg-surface-3 px-6 py-4">
<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" />
@@ -573,34 +637,21 @@ export function ConversationPage() {
)}
{messages.slice(Math.max(0, messages.length - displayCount)).map((m, sliceIdx) => {
const idx = Math.max(0, messages.length - displayCount) + sliceIdx;
// A "run" is consecutive bubbles from the same sender with
// nothing between them. Call-event separators break the run —
// a bubble whose immediate next neighbour is a call_event must
// anchor the avatar, even if another bubble from the same
// sender appears after the separator.
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;
// Anchor avatar on the LAST message of a run so it aligns with
// the bubble's tail (bottom corner). Tail is bottom-left for
// mine, bottom-right for peer — see rounded-[…_4px_…] above.
const isLastOfRun =
!nextRaw || nextRaw.senderId !== m.senderId || nextIsCallEvent;
// DM fallback: if member lookup fails (e.g. transient sync), fall
// back to conversation.peer so the peer's avatar still resolves.
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);
memberProfile ?? (m.senderId !== myId ? (conversation?.peer ?? null) : null);
return (
<li key={m.id}>
{firstUnreadRef.current === m.id && (
{firstUnreadId === m.id && (
<div
aria-label="Neue Nachrichten"
className="my-2 flex items-center gap-3 px-2"
@@ -622,6 +673,7 @@ export function ConversationPage() {
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
? {
@@ -666,10 +718,36 @@ export function ConversationPage() {
)}
</div>
<TypingIndicator
typingUserIds={typingUserIds}
members={conversation?.members ?? []}
/>
{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
@@ -682,7 +760,7 @@ export function ConversationPage() {
</div>
)}
<form onSubmit={handleSend} className="border-t border-line bg-surface-3 p-4">
<form onSubmit={handleSend} className="discord-chat-surface bg-surface-3 px-5 pb-5 pt-2">
{sendError && (
<div className="mb-2">
<Banner>{sendError}</Banner>
@@ -690,7 +768,7 @@ export function ConversationPage() {
)}
{replyTo && (
<div className="mb-2 flex items-stretch gap-2 rounded-lg border border-line bg-surface-2 p-2 text-sm">
<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">
@@ -704,6 +782,7 @@ export function ConversationPage() {
{(() => {
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;
@@ -714,7 +793,7 @@ export function ConversationPage() {
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"
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>
@@ -727,22 +806,19 @@ export function ConversationPage() {
<AttachmentPreview
key={idx}
file={file}
onRemove={() =>
setAttachments((prev) => prev.filter((_, i) => i !== idx))
}
onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
/>
))}
</div>
)}
<div className="relative flex items-end gap-2">
<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) => {
// Replace `@{query}` at `start..caret` with `@{username} `.
const start = mentionState.start;
const before = text.slice(0, start);
const afterCaret = text.slice(start + 1 + mentionState.query.length);
@@ -750,7 +826,6 @@ export function ConversationPage() {
const next = before + inserted + afterCaret;
setText(next);
setMentionState(null);
// Restore caret position after inserted mention.
const caret = (before + inserted).length;
requestAnimationFrame(() => {
const el = composerRef.current;
@@ -774,10 +849,22 @@ export function ConversationPage() {
onClick={() => fileInputRef.current?.click()}
aria-label="Datei anhängen"
title="Datei anhängen"
className="inline-flex h-11 w-11 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/50"
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"
@@ -786,7 +873,7 @@ export function ConversationPage() {
aria-label="Emoji einfügen"
title="Emoji einfügen"
aria-expanded={emojiOpen}
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-surface-2 text-fg transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
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>
@@ -826,16 +913,10 @@ export function ConversationPage() {
const next = e.target.value;
setText(next);
if (next.length > 0) notifyTyping();
// Detect an in-progress @mention: find the last '@' before the
// caret, with no whitespace between it and the caret. If
// present, open the autocomplete with the partial query.
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] ?? ''))
) {
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 });
@@ -867,13 +948,13 @@ export function ConversationPage() {
}}
rows={1}
placeholder="Nachricht schreiben…"
className="max-h-40 min-h-[44px] flex-1 resize-none rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
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-11 w-11 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-60"
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>
@@ -887,6 +968,16 @@ export function ConversationPage() {
onClose={() => setForwardTarget(null)}
/>
<PollComposerDialog
open={pollDialogOpen}
sending={pollSending}
error={pollError}
onClose={() => {
if (!pollSending) setPollDialogOpen(false);
}}
onSubmit={handlePollSubmit}
/>
{profilePopover && (
<UserProfilePopover
userId={profilePopover.userId}
@@ -943,7 +1034,7 @@ function SearchBar({
}: SearchBarProps) {
const { t } = useTranslation(['app']);
return (
<div className="flex flex-col gap-2 border-b border-line bg-surface-2 px-4 py-2">
<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
@@ -974,7 +1065,7 @@ function SearchBar({
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"
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>
@@ -983,7 +1074,7 @@ function SearchBar({
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"
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>
@@ -991,7 +1082,7 @@ function SearchBar({
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"
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>
@@ -1000,7 +1091,7 @@ function SearchBar({
<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"
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) => (
@@ -1009,7 +1100,7 @@ function SearchBar({
</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">
<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}
@@ -1024,7 +1115,7 @@ function SearchBar({
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"
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">
@@ -1033,7 +1124,7 @@ function SearchBar({
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"
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>
@@ -1041,10 +1132,6 @@ function SearchBar({
);
}
// Walks `messages` from `idx + step` skipping call_event entries until a
// regular bubble is found or the array boundary is reached. Used to decide
// run-grouping for avatar placement so call separators don't bleed into
// sender continuity.
function computeDeliveryState(args: {
messageId: string;
isGroup: boolean;
@@ -1054,15 +1141,11 @@ function computeDeliveryState(args: {
groupRead: Map<string, Set<string>>;
groupDelivered: Map<string, Set<string>>;
}): 'sent' | 'delivered' | 'read' {
// DM: single peer ack flips state.
if (!args.isGroup) {
if (args.peerReadSet.has(args.messageId)) return 'read';
if (args.peerDeliveredSet.has(args.messageId)) return 'delivered';
return 'sent';
}
// Group: state advances only when ALL recipients have acknowledged. With
// 0 recipients (admin-only group), we keep 'sent' so we don't show
// misleading completed ticks.
if (args.recipientCount === 0) return 'sent';
const reads = args.groupRead.get(args.messageId);
if (reads && reads.size >= args.recipientCount) return 'read';
@@ -1114,9 +1197,7 @@ function PendingBubble({
) : (
<>
<SpinnerIcon className="h-3 w-3 animate-spin" />
<span>
{item.attempts === 0 ? 'Senden…' : 'Wiederhole (' + item.attempts + ')'}
</span>
<span>{item.attempts === 0 ? 'Senden…' : 'Wiederhole (' + item.attempts + ')'}</span>
</>
)}
</div>
@@ -1147,7 +1228,7 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
return () => URL.revokeObjectURL(u);
}, [file, isImage]);
return (
<div className="relative overflow-hidden rounded-lg border border-line bg-surface-2">
<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" />
) : (
@@ -1155,12 +1236,8 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
<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>
<span className="text-fg-muted">{file.type || 'unbekannt'}</span>
<span className="text-fg-muted">{(file.size / 1024).toFixed(0)} KB</span>
</div>
)}
<button