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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user