Files
ChatApp/apps/desktop/src/components/MessageBubble.tsx
T
byGalax 1303c8e26f feat: backup/restore, user profile popover, image compress, video blur, wake lock
- backup/restore dialog + user profile popover components
- image compression, video blur, wake lock utilities
- message cache + conversation messages hook refinements
- call context, active speakers, screen share dialog tweaks
- audio + screen share settings persistence
- refreshed app icons (smaller sizes) across all platforms
2026-04-21 12:11:09 +02:00

705 lines
25 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 { 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 { 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 { ForwardIcon, 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>;
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,
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 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;
// /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' && mine && !message.deletedAt;
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],
);
if (message.deletedAt || localExpired) {
return (
<div className={'flex items-end gap-2 ' + (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-[calc(70%-2.5rem)] rounded-2xl border border-line bg-surface-2 px-3.5 py-1.5 text-xs italic text-fg-muted">
{t('app:chats.deleted')}
</div>
</div>
);
}
if (parsed.kind === 'call_event') {
return <CallEventRow parsed={parsed} mine={mine} time={time} />;
}
return (
<div className={'flex items-end gap-2 ' + (mine ? 'flex-row-reverse' : '')}>
<AvatarSlot
show={isLastOfRun}
url={senderAvatarUrl ?? null}
displayName={senderDisplayName ?? null}
/>
<div
className={
'group relative max-w-[calc(70%-2.5rem)] ' + (groupedWithPrev ? 'mt-0.5' : 'mt-2')
}
>
{editing ? (
<div className="rounded-2xl border border-accent/40 bg-surface-2 p-2 shadow-sm">
<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"
/>
{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"
>
{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 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')
}
>
{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-2/80 text-fg-muted ring-1 ring-inset ring-line')
}
>
<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>
) : (
<>
{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>
)}
{reactions.length > 0 && !editing && (
<div className={'mt-1 flex flex-wrap gap-1 ' + (mine ? 'justify-end' : 'justify-start')}>
{reactions.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)}
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')
}
>
<span>{r.emoji}</span>
<span className="text-[10px] font-medium">{r.count}</span>
</button>
))}
</div>
)}
{!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')
}
>
<div className="flex items-center gap-0.5 rounded-lg border border-line bg-surface-3 p-1 shadow-lg">
<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" />}
/>
)}
{canEdit && (
<ActionButton
label="Edit"
onClick={() => {
setEditText(message.plaintext ?? '');
setEditing(true);
}}
icon={<PencilIcon className="h-4 w-4" />}
/>
)}
{canDelete && (
<ActionButton
label="Delete"
onClick={() => void handleDelete()}
icon={<TrashIcon className="h-4 w-4" />}
tone="danger"
/>
)}
</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 ' +
(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"
>
{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"
>
<XIcon className="h-4 w-4" />
</button>
</div>
)}
</div>
)}
</div>
</div>
);
}
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,
}: {
parsed: { status: string; mediaKind: string; durationSec: number };
mine: boolean;
time: string;
}) {
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';
const label =
status === 'ended'
? mine
? t('app:chats.call_outgoing', { defaultValue: 'Outgoing call' })
: t('app:chats.call_incoming', { defaultValue: 'Incoming call' })
: status === 'missed'
? mine
? t('app:chats.call_no_answer', { defaultValue: 'No answer' })
: t('app:chats.call_missed', { defaultValue: 'Missed call' })
: 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');
}
// 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[] {
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 text-accent"
>
{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' }) {
// 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';
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: () => 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')
}
>
{icon}
</button>
);
}