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
+381 -84
View File
@@ -7,12 +7,14 @@ import {
} from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import type { AggregatedReaction } from '../lib/useMessageReactions';
import { summarizePollVotes } from '../lib/conversationFeatures';
import { extractFirstUrl } from '../lib/useLinkPreview';
import { AttachmentAudio } from './AttachmentAudio';
import { AttachmentGeneric } from './AttachmentGeneric';
@@ -21,7 +23,19 @@ import { AttachmentPdf } from './AttachmentPdf';
import { AttachmentVideo } from './AttachmentVideo';
import { LinkPreviewCard } from './LinkPreviewCard';
import { Avatar } from './Avatar';
import { ForwardIcon, PencilIcon, PhoneIcon, PhoneOffIcon, ReplyIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
import {
CopyIcon,
ForwardIcon,
MoreVerticalIcon,
PencilIcon,
PhoneIcon,
PhoneOffIcon,
ReplyIcon,
SmileIcon,
SpinnerIcon,
TrashIcon,
XIcon,
} from './icons';
const EMOJI_CHOICES = ['👍', '❤️', '😂', '🎉', '🔥', '😮', '😢', '🙏'];
const EDIT_WINDOW_MS = 24 * 60 * 60 * 1000;
@@ -45,6 +59,7 @@ interface Props {
conversationId: string;
reactions: AggregatedReaction[];
onToggleReaction: (emoji: string) => Promise<void>;
onVotePoll?: (emoji: string, optionEmojis: string[]) => Promise<void>;
showSeen?: boolean;
/** Delivery state for own messages: 'sent' / 'delivered' / 'read'. */
deliveryState?: 'sent' | 'delivered' | 'read';
@@ -72,6 +87,7 @@ export function MessageBubble({
conversationId,
reactions,
onToggleReaction,
onVotePoll,
showSeen = false,
deliveryState,
quoted = null,
@@ -93,6 +109,11 @@ export function MessageBubble({
const [busy, setBusy] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
source: 'context' | 'more';
} | null>(null);
const pickerRef = useRef<HTMLDivElement>(null);
const createdAt = new Date(message.createdAt);
@@ -100,6 +121,12 @@ export function MessageBubble({
const withinEditWindow = age < EDIT_WINDOW_MS;
const bodyText = initialText;
const attachments = initialAttachments;
const pollOptionEmojis =
parsed.kind === 'poll' ? parsed.options.map((option) => option.emoji) : [];
const visibleReactions =
pollOptionEmojis.length === 0
? reactions
: reactions.filter((reaction) => !pollOptionEmojis.includes(reaction.emoji));
// /tempmsg ephemeral window. expireMs embedded in plaintext JSON; sender
// fires the soft-delete when the clock runs out. Receivers just watch
@@ -112,9 +139,7 @@ export function MessageBubble({
return () => window.clearInterval(id);
}, [expireMs]);
const msLeft =
expireMs !== undefined
? Math.max(0, expireMs - (tickNow - createdAt.getTime()))
: null;
expireMs !== undefined ? Math.max(0, expireMs - (tickNow - createdAt.getTime())) : null;
useEffect(() => {
if (!mine) return;
@@ -131,9 +156,7 @@ export function MessageBubble({
// Receiver-side auto-hide when the expiry window elapses even if the
// sender's delete hasn't propagated yet (network hiccup, offline-sender).
const [localExpired, setLocalExpired] = useState<boolean>(
msLeft !== null && msLeft <= 0,
);
const [localExpired, setLocalExpired] = useState<boolean>(msLeft !== null && msLeft <= 0);
useEffect(() => {
if (expireMs === undefined) return;
if (localExpired) return;
@@ -147,7 +170,10 @@ export function MessageBubble({
withinEditWindow &&
!message.deletedAt &&
attachments.length === 0;
const canDelete = parsed.kind === 'text' && mine && !message.deletedAt;
const canDelete =
(parsed.kind === 'text' || parsed.kind === 'poll') && mine && !message.deletedAt;
const canCopy = (parsed.kind === 'text' && bodyText.length > 0) || parsed.kind === 'poll';
const hasMoreMenuActions = canCopy || canEdit || canDelete;
const time = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(
createdAt,
@@ -225,9 +251,33 @@ export function MessageBubble({
[onToggleReaction],
);
const copyableText =
parsed.kind === 'poll'
? [parsed.question, ...parsed.options.map((option) => '- ' + option.text)].join('\n')
: bodyText;
const handleCopy = useCallback(async () => {
if (!copyableText.trim()) return;
try {
await navigator.clipboard.writeText(copyableText);
} catch (err: unknown) {
console.warn('copy message failed', err);
}
}, [copyableText]);
const openContextMenu = useCallback((ev: React.MouseEvent) => {
ev.preventDefault();
setContextMenu({ x: ev.clientX, y: ev.clientY, source: 'context' });
}, []);
if (message.deletedAt || localExpired) {
return (
<div className={'flex items-end gap-2 ' + (mine ? 'flex-row-reverse' : '')}>
<div
className={
'-mx-2 flex items-end gap-2 rounded-xl px-2 py-0.5 transition hover:bg-surface-2/45 dark:hover:bg-[#2e3035] ' +
(mine ? 'flex-row-reverse' : '')
}
>
<AvatarSlot
show={isLastOfRun}
url={senderAvatarUrl ?? null}
@@ -236,7 +286,7 @@ export function MessageBubble({
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
: {})}
/>
<div className="max-w-[calc(70%-2.5rem)] rounded-2xl border border-line bg-surface-2 px-3.5 py-1.5 text-xs italic text-fg-muted">
<div className="max-w-[72%] rounded-2xl border border-line bg-surface-2 px-3.5 py-1.5 text-xs italic text-fg-muted dark:bg-[#383a40]">
{t('app:chats.deleted')}
</div>
</div>
@@ -248,19 +298,24 @@ export function MessageBubble({
}
return (
<div className={'flex items-end gap-2 ' + (mine ? 'flex-row-reverse' : '')}>
<div
onContextMenu={openContextMenu}
className={
'-mx-2 flex items-end gap-2 rounded-xl px-2 py-0.5 transition hover:bg-surface-2/45 dark:hover:bg-[#2e3035] ' +
(mine ? 'flex-row-reverse' : '')
}
>
<AvatarSlot
show={isLastOfRun}
url={senderAvatarUrl ?? null}
displayName={senderDisplayName ?? null}
{...(onAvatarClick
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
: {})}
/>
<div
className={
'group relative max-w-[calc(70%-2.5rem)] ' + (groupedWithPrev ? 'mt-0.5' : 'mt-2')
}
>
<div className={'group relative max-w-[72%] ' + (groupedWithPrev ? 'mt-0.5' : 'mt-2')}>
{editing ? (
<div className="rounded-2xl border border-accent/40 bg-surface-2 p-2 shadow-sm">
<div className="rounded-2xl border border-accent/40 bg-surface-2 p-2 shadow-sm dark:bg-[#383a40]">
<textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
@@ -276,7 +331,7 @@ export function MessageBubble({
}}
rows={2}
autoFocus
className="w-full resize-none rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg outline-none focus:ring-2 focus:ring-accent/50"
className="w-full resize-none rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg outline-none focus:ring-2 focus:ring-accent/50 dark:bg-[#313338]"
/>
{editError && (
<p className="mt-1.5 break-words rounded-md border border-rose-500/30 bg-rose-500/10 px-2 py-1 text-[11px] text-rose-600 dark:text-rose-200">
@@ -290,7 +345,7 @@ export function MessageBubble({
setEditing(false);
setEditError(null);
}}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1 text-xs text-fg hover:bg-surface-2"
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1 text-xs text-fg hover:bg-surface-2 dark:bg-[#313338] dark:hover:bg-[#2b2d31]"
>
{t('app:friends.action_cancel')}
</button>
@@ -309,11 +364,11 @@ export function MessageBubble({
<div
data-message-id={message.id}
className={
'break-words px-3.5 py-2 text-sm transition ' +
'break-words px-3.5 py-2 text-sm shadow-sm transition ' +
(highlighted ? 'ring-2 ring-amber-400 ring-offset-2 ring-offset-surface-3 ' : '') +
(mine
? 'rounded-[18px_18px_4px_18px] bg-accent text-accent-fg'
: 'rounded-[18px_18px_18px_4px] border border-line text-fg')
: 'rounded-[18px_18px_18px_4px] bg-surface-2/90 text-fg dark:bg-[#383a40]')
}
>
{quoted && (
@@ -324,14 +379,13 @@ export function MessageBubble({
'mb-1.5 flex w-full cursor-pointer items-stretch gap-2 rounded-md pl-2 pr-2.5 py-1.5 text-left text-xs transition hover:brightness-110 hover:shadow-sm ' +
(mine
? 'bg-white/10 text-accent-fg/90'
: 'bg-surface-2/80 text-fg-muted ring-1 ring-inset ring-line')
: 'bg-surface-3/80 text-fg-muted ring-1 ring-inset ring-line dark:bg-[#313338]')
}
>
<span
aria-hidden="true"
className={
'-ml-1 w-1 shrink-0 rounded-full ' +
(mine ? 'bg-white/70' : 'bg-accent')
'-ml-1 w-1 shrink-0 rounded-full ' + (mine ? 'bg-white/70' : 'bg-accent')
}
/>
<span className="min-w-0 flex-1 pl-1">
@@ -356,6 +410,16 @@ export function MessageBubble({
)}
{message.plaintext === null ? (
<span className="italic opacity-70">cannot decrypt</span>
) : parsed.kind === 'poll' ? (
<PollCard
question={parsed.question}
options={parsed.options}
reactions={reactions}
mine={mine}
onVote={(emoji) =>
onVotePoll ? onVotePoll(emoji, pollOptionEmojis) : onToggleReaction(emoji)
}
/>
) : (
<>
{bodyText.length > 0 && <div>{renderBodyWithMentions(bodyText)}</div>}
@@ -413,12 +477,10 @@ export function MessageBubble({
</div>
)}
{reactions.length > 0 && !editing && (
{visibleReactions.length > 0 && !editing && (
<div className={'mt-1 flex flex-wrap gap-1 ' + (mine ? 'justify-end' : 'justify-start')}>
{reactions.map((r) => (
{visibleReactions.map((r) => (
<button
// Keying by emoji+count makes React remount the chip when the
// count flips, replaying the pop animation. Cheap visual cue.
key={r.emoji + ':' + r.count}
type="button"
onClick={() => void onToggleReaction(r.emoji)}
@@ -426,7 +488,7 @@ export function MessageBubble({
'inline-flex origin-bottom animate-reaction-pop cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(r.mine
? 'border-accent/40 bg-accent/20 text-accent'
: 'border-line bg-surface-2 text-fg hover:bg-surface-3')
: 'border-line bg-surface-2 text-fg hover:bg-surface-3 dark:bg-[#383a40] dark:hover:bg-[#404249]')
}
>
<span>{r.emoji}</span>
@@ -439,11 +501,14 @@ export function MessageBubble({
{!editing && (
<div
className={
'pointer-events-none absolute top-0 z-20 opacity-0 transition group-hover:pointer-events-auto group-hover:opacity-100 ' +
(mine ? 'right-full pr-2' : 'left-full pl-2')
'absolute bottom-full pb-1 z-20 transition ' +
(pickerOpen || contextMenu
? 'pointer-events-auto opacity-100 '
: 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 ') +
(mine ? 'right-2' : 'left-2')
}
>
<div className="flex items-center gap-0.5 rounded-lg border border-line bg-surface-3 p-1 shadow-lg">
<div className="flex items-center gap-0.5 rounded-lg border border-line bg-surface-3 p-1 shadow-lg dark:bg-[#313338]">
<ActionButton
label={t('app:friends.action_accept', { defaultValue: 'React' })}
onClick={() => setPickerOpen((v) => !v)}
@@ -463,22 +528,18 @@ export function MessageBubble({
icon={<ForwardIcon className="h-4 w-4" />}
/>
)}
{canEdit && (
{hasMoreMenuActions && (
<ActionButton
label="Edit"
onClick={() => {
setEditText(message.plaintext ?? '');
setEditing(true);
label="Mehr"
onClick={(ev) => {
const rect = ev.currentTarget.getBoundingClientRect();
setContextMenu({
x: rect.left,
y: rect.bottom + 8,
source: 'more',
});
}}
icon={<PencilIcon className="h-4 w-4" />}
/>
)}
{canDelete && (
<ActionButton
label="Delete"
onClick={() => void handleDelete()}
icon={<TrashIcon className="h-4 w-4" />}
tone="danger"
icon={<MoreVerticalIcon className="h-4 w-4" />}
/>
)}
</div>
@@ -488,7 +549,7 @@ export function MessageBubble({
ref={pickerRef}
role="menu"
className={
'absolute z-30 mt-1 flex gap-1 rounded-lg border border-line bg-surface-3 p-1.5 shadow-xl ' +
'absolute z-30 mt-1 flex gap-1 rounded-lg border border-line bg-surface-3 p-1.5 shadow-xl dark:bg-[#313338] ' +
(mine ? 'right-0' : 'left-0')
}
>
@@ -497,7 +558,7 @@ export function MessageBubble({
key={e}
type="button"
onClick={() => void handlePickEmoji(e)}
className="cursor-pointer rounded-md px-2 py-1 text-lg transition hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
className="cursor-pointer rounded-md px-2 py-1 text-lg transition hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:hover:bg-[#383a40]"
>
{e}
</button>
@@ -505,7 +566,7 @@ export function MessageBubble({
<button
type="button"
onClick={() => setPickerOpen(false)}
className="cursor-pointer rounded-md px-1.5 py-1 text-fg-muted transition hover:bg-surface-2"
className="cursor-pointer rounded-md px-1.5 py-1 text-fg-muted transition hover:bg-surface-2 dark:hover:bg-[#383a40]"
>
<XIcon className="h-4 w-4" />
</button>
@@ -513,11 +574,264 @@ export function MessageBubble({
)}
</div>
)}
{contextMenu && (
<MessageContextMenu
x={contextMenu.x}
y={contextMenu.y}
canCopy={canCopy}
canEdit={canEdit}
canDelete={canDelete}
showQuickActions={contextMenu.source === 'context'}
onClose={() => setContextMenu(null)}
onReact={() => {
setContextMenu(null);
setPickerOpen(true);
}}
{...(onReply
? {
onReply: () => {
setContextMenu(null);
onReply(message);
},
}
: {})}
{...(onForward
? {
onForward: () => {
setContextMenu(null);
onForward(message);
},
}
: {})}
onEdit={() => {
setContextMenu(null);
setEditText(message.plaintext ?? '');
setEditing(true);
}}
onCopy={() => {
setContextMenu(null);
void handleCopy();
}}
onDelete={() => {
setContextMenu(null);
void handleDelete();
}}
/>
)}
</div>
</div>
);
}
function PollCard({
question,
options,
reactions,
onVote,
mine,
}: {
question: string;
options: { id: string; emoji: string; text: string }[];
reactions: AggregatedReaction[];
onVote: (emoji: string) => Promise<void>;
mine: boolean;
}) {
const summary = summarizePollVotes(options, reactions);
const rowBase = mine
? 'border-white/25 bg-white/10 hover:bg-white/15'
: 'border-line bg-surface-3 hover:bg-surface-3/80 dark:bg-[#2b2d31] dark:hover:bg-[#313338]';
const rowVoted = mine ? 'border-white/60 bg-white/25' : 'border-accent/60 bg-accent/20';
const progressTint = mine ? 'bg-white/25' : 'bg-accent/25';
const chipStyle = mine
? 'bg-white/20 text-accent-fg'
: 'bg-surface-2 text-fg dark:bg-[#1e1f22]';
const textMain = mine ? 'text-accent-fg' : 'text-fg';
const textCount = mine ? 'text-accent-fg/90' : 'text-fg';
const textFooter = mine ? 'text-accent-fg/75' : 'text-fg-muted';
const focusRing = mine ? 'focus-visible:ring-white/60' : 'focus-visible:ring-accent/40';
return (
<div className="min-w-[260px] max-w-[360px]">
<p className={'mb-3 text-sm font-semibold leading-snug ' + textMain}>
{question || 'Umfrage'}
</p>
<div className="space-y-2">
{summary.options.map((option, idx) => (
<button
key={option.id}
type="button"
onClick={() => void onVote(option.emoji)}
className={
'relative flex w-full cursor-pointer items-center gap-2.5 overflow-hidden rounded-lg border px-3 py-2 text-left text-sm transition focus:outline-none focus-visible:ring-2 ' +
focusRing +
' ' +
textMain +
' ' +
(option.mine ? rowVoted : rowBase)
}
>
<span
aria-hidden="true"
style={{ width: option.percent + '%' }}
className={'absolute inset-y-0 left-0 transition-[width] ' + progressTint}
/>
<span
aria-hidden="true"
className={
'relative flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-sm font-bold tabular-nums ' +
chipStyle
}
>
{idx + 1}
</span>
<span className="relative min-w-0 flex-1 break-words font-medium">{option.text}</span>
<span className={'relative shrink-0 text-xs font-semibold tabular-nums ' + textCount}>
{option.count} · {option.percent}%
</span>
</button>
))}
</div>
<p className={'mt-2 text-[11px] ' + textFooter}>
{summary.totalVotes === 1 ? '1 Stimme' : summary.totalVotes + ' Stimmen'}
</p>
</div>
);
}
function MessageContextMenu({
x,
y,
canCopy,
canEdit,
canDelete,
showQuickActions,
onClose,
onReact,
onReply,
onForward,
onEdit,
onCopy,
onDelete,
}: {
x: number;
y: number;
canCopy: boolean;
canEdit: boolean;
canDelete: boolean;
showQuickActions: boolean;
onClose: () => void;
onReact: () => void;
onReply?: () => void;
onForward?: () => void;
onEdit: () => void;
onCopy: () => void;
onDelete: () => void;
}) {
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
function onDown(e: MouseEvent) {
const target = e.target as HTMLElement | null;
if (target?.closest('[data-message-context-menu]')) return;
onClose();
}
window.addEventListener('keydown', onKey);
window.addEventListener('mousedown', onDown);
return () => {
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onDown);
};
}, [onClose]);
const left = Math.min(Math.max(8, x), window.innerWidth - 232);
const top = Math.min(Math.max(8, y), window.innerHeight - 280);
return createPortal(
<div
data-message-context-menu
role="menu"
style={{ left, top, width: 224 }}
className="fixed z-[90] rounded-xl border border-line bg-surface-3 p-1.5 shadow-2xl dark:bg-[#313338]"
>
{showQuickActions && (
<>
<MenuItem
label="Reaktion hinzufügen"
icon={<SmileIcon className="h-4 w-4" />}
onClick={onReact}
/>
{onReply && (
<MenuItem
label="Antworten"
icon={<ReplyIcon className="h-4 w-4" />}
onClick={onReply}
/>
)}
{onForward && (
<MenuItem
label="Weiterleiten"
icon={<ForwardIcon className="h-4 w-4" />}
onClick={onForward}
/>
)}
{(canCopy || canEdit || canDelete) && <MenuSeparator />}
</>
)}
{canCopy && (
<MenuItem label="Text kopieren" icon={<CopyIcon className="h-4 w-4" />} onClick={onCopy} />
)}
{canEdit && (
<MenuItem label="Bearbeiten" icon={<PencilIcon className="h-4 w-4" />} onClick={onEdit} />
)}
{canDelete && (
<MenuItem
label="Löschen"
icon={<TrashIcon className="h-4 w-4" />}
onClick={onDelete}
tone="danger"
/>
)}
</div>,
document.body,
);
}
function MenuSeparator() {
return <div aria-hidden="true" className="my-1 h-px bg-line" />;
}
function MenuItem({
label,
icon,
onClick,
tone,
}: {
label: string;
icon: React.ReactNode;
onClick: () => void;
tone?: 'danger';
}) {
return (
<button
type="button"
role="menuitem"
onClick={onClick}
className={
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(tone === 'danger'
? 'text-rose-500 hover:bg-rose-500/15 dark:text-rose-300'
: 'text-fg hover:bg-surface-2 dark:hover:bg-[#383a40]')
}
>
<span className="shrink-0 text-fg-muted">{icon}</span>
<span className="min-w-0 flex-1 truncate">{label}</span>
</button>
);
}
function AvatarSlot({
show,
url,
@@ -545,13 +859,7 @@ function AvatarSlot({
</button>
);
}
return (
<Avatar
url={url}
displayName={displayName ?? ''}
className="h-8 w-8 text-xs"
/>
);
return <Avatar url={url} displayName={displayName ?? ''} className="h-8 w-8 text-xs" />;
}
function CallEventRow({
@@ -608,10 +916,6 @@ function formatDuration(totalSec: number): string {
return m + ':' + s.toString().padStart(2, '0');
}
// Splits body text on `@username` tokens, rendering matches as highlighted
// pills. Username alphabet matches Supabase citext usernames: alphanumerics
// + underscores, length 1..32 (we don't bound here — regex is permissive
// and keys off a leading `@` with an alnum/underscore follow).
const MENTION_RE = /@([A-Za-z0-9_]{1,32})/g;
function renderBodyWithMentions(text: string): React.ReactNode[] {
@@ -624,7 +928,7 @@ function renderBodyWithMentions(text: string): React.ReactNode[] {
out.push(
<span
key={m.index + ':' + m[1]}
className="rounded bg-accent/20 px-1 text-accent"
className="rounded bg-accent/20 px-1 font-medium text-inherit ring-1 ring-inset ring-accent/25"
>
{m[0]}
</span>,
@@ -636,31 +940,24 @@ function renderBodyWithMentions(text: string): React.ReactNode[] {
}
function DeliveryTicks({ state }: { state: 'sent' | 'delivered' | 'read' }) {
// One checkmark for sent, two for delivered/read. Read shifts color to the
// accent to match WhatsApp/Telegram blue-tick convention.
const color =
state === 'read'
? 'text-sky-500 dark:text-sky-400'
: 'text-fg-muted';
const color = state === 'read' ? 'text-sky-500 dark:text-sky-400' : 'text-fg-muted';
return (
<span
aria-label={
state === 'read'
? 'Gelesen'
: state === 'delivered'
? 'Zugestellt'
: 'Gesendet'
}
title={
state === 'read'
? 'Gelesen'
: state === 'delivered'
? 'Zugestellt'
: 'Gesendet'
}
aria-label={state === 'read' ? 'Gelesen' : state === 'delivered' ? 'Zugestellt' : 'Gesendet'}
title={state === 'read' ? 'Gelesen' : state === 'delivered' ? 'Zugestellt' : 'Gesendet'}
className={'flex items-center ' + color}
>
<svg viewBox="0 0 16 12" width="14" height="10" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<svg
viewBox="0 0 16 12"
width="14"
height="10"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{state === 'sent' ? (
<polyline points="2 7 6 11 14 1" />
) : (
@@ -681,7 +978,7 @@ function ActionButton({
tone,
}: {
label: string;
onClick: () => void;
onClick: (ev: React.MouseEvent<HTMLButtonElement>) => void;
icon: React.ReactNode;
tone?: 'danger';
}) {
@@ -695,7 +992,7 @@ function ActionButton({
'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(tone === 'danger'
? 'text-fg-muted hover:bg-rose-500/20 hover:text-rose-500 dark:hover:text-rose-200'
: 'text-fg-muted hover:bg-surface-2 hover:text-fg')
: 'text-fg-muted hover:bg-surface-2 hover:text-fg dark:hover:bg-[#383a40]')
}
>
{icon}