cb3cbd8827
In group conversations the "Outgoing/Incoming/Missed call" system pill
gave no clue WHO triggered the event — fine in a 1:1 where the only two
players are obvious, useless in a group with three+ members. Discord
puts the caller's name in the pill; mirror that.
CallEventRow now takes a senderDisplayName prop (plumbed through from
MessageBubble) and switches non-own labels to the name-aware variants:
* ended + !mine + name → "{name} hat einen Anruf gestartet"
* missed + !mine + name → "Verpasster Anruf von {name}"
* declined + !mine + name → "Anruf von {name} abgelehnt"
Own events (mine) stay generic ("Outgoing call" / "No answer") since
the user already knows they were the initiator. Fallback path without
a name keeps the previous generic labels so nothing regresses if the
sender is unresolvable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1034 lines
35 KiB
TypeScript
1034 lines
35 KiB
TypeScript
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
|
import {
|
|
type DecryptedMessage,
|
|
editEncryptedMessage,
|
|
parseMessagePayload,
|
|
softDeleteMessage,
|
|
} 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';
|
|
import { AttachmentImage } from './AttachmentImage';
|
|
import { AttachmentPdf } from './AttachmentPdf';
|
|
import { AttachmentVideo } from './AttachmentVideo';
|
|
import { LinkPreviewCard } from './LinkPreviewCard';
|
|
import { Avatar } from './Avatar';
|
|
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;
|
|
|
|
export interface QuotedRef {
|
|
id: string;
|
|
senderName: string;
|
|
snippet: string;
|
|
isAttachment: boolean;
|
|
deleted: boolean;
|
|
}
|
|
|
|
interface Props {
|
|
message: DecryptedMessage;
|
|
mine: boolean;
|
|
groupedWithPrev: boolean;
|
|
/** Last message of a run from this sender — anchor avatar on this row. */
|
|
isLastOfRun?: boolean;
|
|
senderDisplayName?: string | null | undefined;
|
|
senderAvatarUrl?: string | null | undefined;
|
|
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';
|
|
/** Resolved quoted message info (parent does the lookup). */
|
|
quoted?: QuotedRef | null;
|
|
/** Tap-to-jump on quote bubble. Receives the quoted message's id. */
|
|
onJumpToMessage?: (id: string) => void;
|
|
/** Hover action: parent receives current message to start a reply. */
|
|
onReply?: (m: DecryptedMessage) => void;
|
|
/** Hover action: parent opens forward dialog for current message. */
|
|
onForward?: (m: DecryptedMessage) => void;
|
|
/** Click on the message's avatar surfaces the author's profile card. */
|
|
onAvatarClick?: (userId: string, ev: React.MouseEvent) => void;
|
|
/** Highlighted state — set briefly after a jump. */
|
|
highlighted?: boolean;
|
|
}
|
|
|
|
export function MessageBubble({
|
|
message,
|
|
mine,
|
|
groupedWithPrev,
|
|
isLastOfRun = false,
|
|
senderDisplayName,
|
|
senderAvatarUrl,
|
|
conversationId,
|
|
reactions,
|
|
onToggleReaction,
|
|
onVotePoll,
|
|
showSeen = false,
|
|
deliveryState,
|
|
quoted = null,
|
|
onJumpToMessage,
|
|
onReply,
|
|
onForward,
|
|
onAvatarClick,
|
|
highlighted = false,
|
|
}: Props) {
|
|
const { t } = useTranslation(['app']);
|
|
const { session, device } = useAuth();
|
|
|
|
const parsed = parseMessagePayload(message.plaintext);
|
|
const initialText = parsed.kind === 'text' ? parsed.text : '';
|
|
const initialAttachments = parsed.kind === 'text' ? parsed.attachments : [];
|
|
|
|
const [editing, setEditing] = useState(false);
|
|
const [editText, setEditText] = useState(initialText);
|
|
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);
|
|
const age = Date.now() - createdAt.getTime();
|
|
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
|
|
// the deletedAt flip via realtime.
|
|
const expireMs = parsed.kind === 'text' ? parsed.expireMs : undefined;
|
|
const [tickNow, setTickNow] = useState<number>(() => Date.now());
|
|
useEffect(() => {
|
|
if (expireMs === undefined) return;
|
|
const id = window.setInterval(() => setTickNow(Date.now()), 1000);
|
|
return () => window.clearInterval(id);
|
|
}, [expireMs]);
|
|
const msLeft =
|
|
expireMs !== undefined ? Math.max(0, expireMs - (tickNow - createdAt.getTime())) : null;
|
|
|
|
useEffect(() => {
|
|
if (!mine) return;
|
|
if (expireMs === undefined) return;
|
|
if (message.deletedAt) return;
|
|
const remaining = Math.max(0, expireMs - age);
|
|
const timer = window.setTimeout(() => {
|
|
void softDeleteMessage(supabase, message.id).catch((err: unknown) => {
|
|
console.warn('ephemeral auto-delete failed', err);
|
|
});
|
|
}, remaining);
|
|
return () => window.clearTimeout(timer);
|
|
}, [mine, expireMs, age, message.id, message.deletedAt]);
|
|
|
|
// 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);
|
|
useEffect(() => {
|
|
if (expireMs === undefined) return;
|
|
if (localExpired) return;
|
|
const remaining = Math.max(0, expireMs - age);
|
|
const t = window.setTimeout(() => setLocalExpired(true), remaining);
|
|
return () => window.clearTimeout(t);
|
|
}, [expireMs, age, localExpired]);
|
|
const canEdit =
|
|
parsed.kind === 'text' &&
|
|
mine &&
|
|
withinEditWindow &&
|
|
!message.deletedAt &&
|
|
attachments.length === 0;
|
|
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,
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!pickerOpen) return;
|
|
function onClickOutside(e: MouseEvent) {
|
|
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
|
|
setPickerOpen(false);
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', onClickOutside);
|
|
return () => document.removeEventListener('mousedown', onClickOutside);
|
|
}, [pickerOpen]);
|
|
|
|
const handleEditSave = useCallback(async () => {
|
|
if (!session || !device) return;
|
|
const trimmed = editText.trim();
|
|
if (!trimmed || trimmed === bodyText) {
|
|
setEditing(false);
|
|
setEditError(null);
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
setEditError(null);
|
|
try {
|
|
const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id);
|
|
if (!priv) throw new Error('private key not loaded');
|
|
await editEncryptedMessage({
|
|
client: supabase,
|
|
messageId: message.id,
|
|
conversationId,
|
|
newPlaintext: trimmed,
|
|
senderUserId: session.user.id,
|
|
senderDeviceId: device.id,
|
|
senderPrivateKey: priv,
|
|
});
|
|
setEditing(false);
|
|
} catch (err: unknown) {
|
|
const code = extractErrorCode(err);
|
|
setEditError(
|
|
code
|
|
? t('errors:' + code, { defaultValue: t('errors:generic') })
|
|
: err instanceof Error
|
|
? err.message
|
|
: t('errors:generic'),
|
|
);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}, [editText, message.id, message.plaintext, conversationId, session, device, t]);
|
|
|
|
const handleDelete = useCallback(async () => {
|
|
if (busy) return;
|
|
setBusy(true);
|
|
try {
|
|
await softDeleteMessage(supabase, message.id);
|
|
} catch (err: unknown) {
|
|
console.error('delete failed', err);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}, [busy, message.id]);
|
|
|
|
const handlePickEmoji = useCallback(
|
|
async (emoji: string) => {
|
|
setPickerOpen(false);
|
|
try {
|
|
await onToggleReaction(emoji);
|
|
} catch (err: unknown) {
|
|
console.error('toggleReaction failed', err);
|
|
}
|
|
},
|
|
[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={
|
|
'-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="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>
|
|
);
|
|
}
|
|
|
|
if (parsed.kind === 'call_event') {
|
|
return (
|
|
<CallEventRow
|
|
parsed={parsed}
|
|
mine={mine}
|
|
time={time}
|
|
senderDisplayName={senderDisplayName ?? null}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<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-[72%] ' + (groupedWithPrev ? 'mt-0.5' : 'mt-2')}>
|
|
{editing ? (
|
|
<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)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
void handleEditSave();
|
|
}
|
|
if (e.key === 'Escape') {
|
|
setEditing(false);
|
|
setEditError(null);
|
|
}
|
|
}}
|
|
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 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">
|
|
{editError}
|
|
</p>
|
|
)}
|
|
<div className="mt-1.5 flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
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 dark:bg-[#313338] dark:hover:bg-[#2b2d31]"
|
|
>
|
|
{t('app:friends.action_cancel')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={busy}
|
|
onClick={() => void handleEditSave()}
|
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1 text-xs font-semibold text-accent-fg hover:brightness-110 disabled:opacity-60"
|
|
>
|
|
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
|
|
<span>{t('common:save', { defaultValue: 'Save' })}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div
|
|
data-message-id={message.id}
|
|
className={
|
|
'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] bg-surface-2/90 text-fg dark:bg-[#383a40]')
|
|
}
|
|
>
|
|
{quoted && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onJumpToMessage?.(quoted.id)}
|
|
className={
|
|
'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-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')
|
|
}
|
|
/>
|
|
<span className="min-w-0 flex-1 pl-1">
|
|
<span
|
|
className={
|
|
'flex items-center gap-1 truncate text-[11px] font-semibold ' +
|
|
(mine ? 'text-accent-fg' : 'text-accent')
|
|
}
|
|
>
|
|
<ReplyIcon className="h-3 w-3 shrink-0" />
|
|
<span className="truncate">{quoted.senderName}</span>
|
|
</span>
|
|
<span className="mt-0.5 block truncate opacity-80">
|
|
{quoted.deleted
|
|
? t('app:chats.deleted')
|
|
: quoted.isAttachment && !quoted.snippet
|
|
? '📎 ' + t('app:chats.attachment', { defaultValue: 'Anhang' })
|
|
: quoted.snippet}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
)}
|
|
{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>}
|
|
{bodyText.length > 0 &&
|
|
(() => {
|
|
const url = extractFirstUrl(bodyText);
|
|
return url ? <LinkPreviewCard url={url} /> : null;
|
|
})()}
|
|
{attachments.map((a) => {
|
|
if (a.mimeType.startsWith('audio/')) {
|
|
return <AttachmentAudio key={a.id} handle={a} />;
|
|
}
|
|
if (a.mimeType.startsWith('image/')) {
|
|
return <AttachmentImage key={a.id} handle={a} />;
|
|
}
|
|
if (a.mimeType.startsWith('video/')) {
|
|
return <AttachmentVideo key={a.id} handle={a} />;
|
|
}
|
|
if (a.mimeType === 'application/pdf') {
|
|
return <AttachmentPdf key={a.id} handle={a} />;
|
|
}
|
|
return <AttachmentGeneric key={a.id} handle={a} />;
|
|
})}
|
|
</>
|
|
)}
|
|
<div
|
|
className={
|
|
'mt-1 flex items-center gap-1.5 text-[10px] ' +
|
|
(mine ? 'text-accent-fg/75' : 'text-fg-muted')
|
|
}
|
|
>
|
|
<span>{time}</span>
|
|
{message.editedAt && !message.deletedAt && (
|
|
<span className="italic">· {t('app:chats.edited')}</span>
|
|
)}
|
|
{msLeft !== null && msLeft > 0 && (
|
|
<span
|
|
className={
|
|
'inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider ' +
|
|
(mine ? 'bg-white/20' : 'bg-rose-500/15 text-rose-600 dark:text-rose-300')
|
|
}
|
|
title="Selbstzerstörung"
|
|
>
|
|
⏱ {Math.ceil(msLeft / 1000)}s
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{mine && !editing && !message.deletedAt && deliveryState && (
|
|
<div className="mt-0.5 flex items-center justify-end gap-1 text-[10px] text-fg-muted">
|
|
<DeliveryTicks state={deliveryState} />
|
|
{showSeen && <span>{t('app:chats.seen')}</span>}
|
|
</div>
|
|
)}
|
|
|
|
{visibleReactions.length > 0 && !editing && (
|
|
<div className={'mt-1 flex flex-wrap gap-1 ' + (mine ? 'justify-end' : 'justify-start')}>
|
|
{visibleReactions.map((r) => (
|
|
<button
|
|
key={r.emoji + ':' + r.count}
|
|
type="button"
|
|
onClick={() => void onToggleReaction(r.emoji)}
|
|
className={
|
|
'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 dark:bg-[#383a40] dark:hover:bg-[#404249]')
|
|
}
|
|
>
|
|
<span>{r.emoji}</span>
|
|
<span className="text-[10px] font-medium">{r.count}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{!editing && (
|
|
<div
|
|
className={
|
|
'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 dark:bg-[#313338]">
|
|
<ActionButton
|
|
label={t('app:friends.action_accept', { defaultValue: 'React' })}
|
|
onClick={() => setPickerOpen((v) => !v)}
|
|
icon={<SmileIcon className="h-4 w-4" />}
|
|
/>
|
|
{onReply && !message.deletedAt && (
|
|
<ActionButton
|
|
label={t('app:chats.reply', { defaultValue: 'Antworten' })}
|
|
onClick={() => onReply(message)}
|
|
icon={<ReplyIcon className="h-4 w-4" />}
|
|
/>
|
|
)}
|
|
{onForward && !message.deletedAt && (
|
|
<ActionButton
|
|
label={t('app:chats.forward', { defaultValue: 'Weiterleiten' })}
|
|
onClick={() => onForward(message)}
|
|
icon={<ForwardIcon className="h-4 w-4" />}
|
|
/>
|
|
)}
|
|
{hasMoreMenuActions && (
|
|
<ActionButton
|
|
label="Mehr"
|
|
onClick={(ev) => {
|
|
const rect = ev.currentTarget.getBoundingClientRect();
|
|
setContextMenu({
|
|
x: rect.left,
|
|
y: rect.bottom + 8,
|
|
source: 'more',
|
|
});
|
|
}}
|
|
icon={<MoreVerticalIcon className="h-4 w-4" />}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{pickerOpen && (
|
|
<div
|
|
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 dark:bg-[#313338] ' +
|
|
(mine ? 'right-0' : 'left-0')
|
|
}
|
|
>
|
|
{EMOJI_CHOICES.map((e) => (
|
|
<button
|
|
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 dark:hover:bg-[#383a40]"
|
|
>
|
|
{e}
|
|
</button>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={() => setPickerOpen(false)}
|
|
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>
|
|
</div>
|
|
)}
|
|
</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,
|
|
displayName,
|
|
onClick,
|
|
}: {
|
|
show: boolean;
|
|
url: string | null;
|
|
displayName: string | null;
|
|
onClick?: (ev: React.MouseEvent) => void;
|
|
}) {
|
|
if (!show) {
|
|
return <div aria-hidden="true" className="h-8 w-8 shrink-0" />;
|
|
}
|
|
if (onClick) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
data-user-popover-trigger
|
|
onClick={onClick}
|
|
className="shrink-0 cursor-pointer rounded-full transition hover:ring-2 hover:ring-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
|
aria-label={displayName ?? 'Profil'}
|
|
>
|
|
<Avatar url={url} displayName={displayName ?? ''} className="h-8 w-8 text-xs" />
|
|
</button>
|
|
);
|
|
}
|
|
return <Avatar url={url} displayName={displayName ?? ''} className="h-8 w-8 text-xs" />;
|
|
}
|
|
|
|
function CallEventRow({
|
|
parsed,
|
|
mine,
|
|
time,
|
|
senderDisplayName,
|
|
}: {
|
|
parsed: { status: string; mediaKind: string; durationSec: number };
|
|
mine: boolean;
|
|
time: string;
|
|
/** Discord-parity: in group chats the system pill should say WHO
|
|
* started/missed the call. Null means we don't know (fall back to the
|
|
* legacy generic labels). */
|
|
senderDisplayName: string | null;
|
|
}) {
|
|
const { t } = useTranslation(['app']);
|
|
const status = parsed.status;
|
|
const isMissed = status === 'missed' || status === 'declined';
|
|
|
|
const Icon = isMissed ? PhoneOffIcon : PhoneIcon;
|
|
const tone = isMissed
|
|
? 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200'
|
|
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200';
|
|
|
|
// For non-own events we prefer the name-aware label so group chats
|
|
// make it clear who triggered the call event. Own events stay generic
|
|
// ("Outgoing call" / "No answer") since the user already knows they
|
|
// were the initiator.
|
|
const hasName = !mine && !!senderDisplayName;
|
|
const label =
|
|
status === 'ended'
|
|
? mine
|
|
? t('app:chats.call_outgoing', { defaultValue: 'Outgoing call' })
|
|
: hasName
|
|
? t('app:chats.call_started_by', {
|
|
name: senderDisplayName,
|
|
defaultValue: '{{name}} hat einen Anruf gestartet',
|
|
})
|
|
: t('app:chats.call_incoming', { defaultValue: 'Incoming call' })
|
|
: status === 'missed'
|
|
? mine
|
|
? t('app:chats.call_no_answer', { defaultValue: 'No answer' })
|
|
: hasName
|
|
? t('app:chats.call_missed_by', {
|
|
name: senderDisplayName,
|
|
defaultValue: 'Verpasster Anruf von {{name}}',
|
|
})
|
|
: t('app:chats.call_missed', { defaultValue: 'Missed call' })
|
|
: hasName
|
|
? t('app:chats.call_declined_by', {
|
|
name: senderDisplayName,
|
|
defaultValue: 'Anruf von {{name}} abgelehnt',
|
|
})
|
|
: t('app:chats.call_declined', { defaultValue: 'Call declined' });
|
|
|
|
const duration = parsed.durationSec > 0 ? formatDuration(parsed.durationSec) : null;
|
|
|
|
return (
|
|
<div className="my-2 flex justify-center">
|
|
<div
|
|
className={
|
|
'inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium ' + tone
|
|
}
|
|
>
|
|
<Icon className="h-3.5 w-3.5" />
|
|
<span>{label}</span>
|
|
{duration && <span className="font-mono text-[11px] opacity-80">· {duration}</span>}
|
|
<span className="text-[10px] opacity-60">· {time}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatDuration(totalSec: number): string {
|
|
const m = Math.floor(totalSec / 60);
|
|
const s = totalSec % 60;
|
|
if (m === 0) return s + 's';
|
|
return m + ':' + s.toString().padStart(2, '0');
|
|
}
|
|
|
|
const MENTION_RE = /@([A-Za-z0-9_]{1,32})/g;
|
|
|
|
function renderBodyWithMentions(text: string): React.ReactNode[] {
|
|
const out: React.ReactNode[] = [];
|
|
let lastIdx = 0;
|
|
let m: RegExpExecArray | null;
|
|
MENTION_RE.lastIndex = 0;
|
|
while ((m = MENTION_RE.exec(text)) !== null) {
|
|
if (m.index > lastIdx) out.push(text.slice(lastIdx, m.index));
|
|
out.push(
|
|
<span
|
|
key={m.index + ':' + m[1]}
|
|
className="rounded bg-accent/20 px-1 font-medium text-inherit ring-1 ring-inset ring-accent/25"
|
|
>
|
|
{m[0]}
|
|
</span>,
|
|
);
|
|
lastIdx = m.index + m[0].length;
|
|
}
|
|
if (lastIdx < text.length) out.push(text.slice(lastIdx));
|
|
return out;
|
|
}
|
|
|
|
function DeliveryTicks({ state }: { state: 'sent' | 'delivered' | 'read' }) {
|
|
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'}
|
|
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"
|
|
>
|
|
{state === 'sent' ? (
|
|
<polyline points="2 7 6 11 14 1" />
|
|
) : (
|
|
<>
|
|
<polyline points="1 7 5 11 11 2" />
|
|
<polyline points="6 11 10 11 14 1" />
|
|
</>
|
|
)}
|
|
</svg>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function ActionButton({
|
|
label,
|
|
onClick,
|
|
icon,
|
|
tone,
|
|
}: {
|
|
label: string;
|
|
onClick: (ev: React.MouseEvent<HTMLButtonElement>) => void;
|
|
icon: React.ReactNode;
|
|
tone?: 'danger';
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
aria-label={label}
|
|
title={label}
|
|
onClick={onClick}
|
|
className={
|
|
'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 dark:hover:bg-[#383a40]')
|
|
}
|
|
>
|
|
{icon}
|
|
</button>
|
|
);
|
|
}
|