feat: voice messages, offline queue, delivery ticks, volume slider, admin + scaling

- Voice messages: MediaRecorder → encrypted attachment, custom waveform
  player via OfflineAudioContext, 60s limit + live mic-level meter
- Offline message queue: localStorage outbox, exponential backoff retries,
  optimistic pending bubble with retry/discard
- Delivery indicator: message_deliveries table + RLS (reciprocal receipts),
  ✓ / ✓✓ / ✓✓-blue tick states, group-aware (all members must ack)
- Per-participant volume slider in calls via right-click tile menu,
  persisted to localStorage, applied to attached audio elements
- Group call scaling: grid up to 12 tiles with pagination,
  active-speaker auto-promotion in fullscreen
- Push notifications scaffolding: service worker, VAPID subscription
  registration, notify-push edge function skeleton
- Backup recovery code: 24-char base32 code (~120 bits entropy) as
  alternative decrypt path, restore UI with mode toggle
- Admin panel: conversations list, audit log (admin_audit_log table +
  admin_log_action RPC), audit entry on user flag toggle
- Search v2: sender filter, attachment-only toggle, date range
- Reactions pop animation (scale 0.4→1.15→1 on count change)
- Message list windowing (150 default, expand via IntersectionObserver)
- Stub cleanup: removed dead ScreenshareStub from CallParticipantTile

Fixes:
- Focus-triggered flicker: dropped window.focus listeners in three spots,
  throttled visibilitychange/online wake-refreshes to 30s, keep existing
  data visible during background re-syncs (no more spinner on every click)
- Voice attachment audio element collapsed to 0px on peer side — now
  forces 280px min-width on bubble

Migrations (push required):
  20260421000001_message_deliveries.sql
  20260421000002_admin_audit_log.sql

Server TODO:
  VAPID keys + notify-push edge function deploy
This commit is contained in:
2026-04-21 01:14:16 +02:00
parent da85f0ba54
commit a04ecf7a19
40 changed files with 4286 additions and 430 deletions
@@ -0,0 +1,239 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useEffect, useMemo, useRef, useState } from 'react';
import { supabase } from '../lib/supabase';
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
interface Props {
handle: AttachmentHandle;
}
const BAR_COUNT = 48;
// Custom voice-message player with waveform visualisation. Decoded peaks are
// computed once per blob via OfflineAudioContext so playback only carries the
// rendered DOM. Falls back to a rectangular bar if decoding fails (e.g. the
// blob mime is recognised by <audio> but not by AudioContext).
export function AttachmentAudio({ handle }: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [arrayBuf, setArrayBuf] = useState<ArrayBuffer | null>(null);
const [error, setError] = useState<string | null>(null);
const [peaks, setPeaks] = useState<number[] | null>(null);
const [duration, setDuration] = useState<number>(0);
const [position, setPosition] = useState<number>(0);
const [playing, setPlaying] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
let cancelled = false;
let url: string | null = null;
setError(null);
setBlobUrl(null);
setArrayBuf(null);
downloadAndDecryptAttachment({ client: supabase, handle })
.then(async (blob) => {
if (cancelled) return;
url = URL.createObjectURL(blob);
setBlobUrl(url);
const buf = await blob.arrayBuffer();
if (!cancelled) setArrayBuf(buf);
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'download failed');
}
});
return () => {
cancelled = true;
if (url) URL.revokeObjectURL(url);
};
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
// Compute peaks via OfflineAudioContext. Cheap O(n) scan over PCM samples
// bucketed into BAR_COUNT bars. Done once per attachment.
useEffect(() => {
if (!arrayBuf) return;
let cancelled = false;
const Ctx =
window.AudioContext ||
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
const ctx = new Ctx();
ctx
.decodeAudioData(arrayBuf.slice(0))
.then((decoded) => {
if (cancelled) return;
setDuration(decoded.duration);
const channel = decoded.getChannelData(0);
const bucket = Math.max(1, Math.floor(channel.length / BAR_COUNT));
const out = new Array<number>(BAR_COUNT).fill(0);
for (let i = 0; i < BAR_COUNT; i++) {
let max = 0;
const start = i * bucket;
const end = Math.min(channel.length, start + bucket);
for (let j = start; j < end; j++) {
const v = Math.abs(channel[j]!);
if (v > max) max = v;
}
out[i] = max;
}
// Normalize so loudest peak is 1; keeps quiet recordings visible.
const peak = Math.max(...out, 0.001);
setPeaks(out.map((v) => v / peak));
})
.catch(() => {
// Fall through — UI shows a flat bar but playback still works.
})
.finally(() => {
void ctx.close().catch(() => {});
});
return () => {
cancelled = true;
};
}, [arrayBuf]);
const fallbackPeaks = useMemo(
() => (peaks ? null : Array.from({ length: BAR_COUNT }, () => 0.5)),
[peaks],
);
const visiblePeaks = peaks ?? fallbackPeaks!;
const progress = duration > 0 ? position / duration : 0;
const onTogglePlay = () => {
const el = audioRef.current;
if (!el || !blobUrl) return;
if (el.paused) void el.play();
else el.pause();
};
const onSeek = (e: React.MouseEvent<HTMLDivElement>) => {
const el = audioRef.current;
if (!el || duration === 0) return;
const rect = e.currentTarget.getBoundingClientRect();
const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
el.currentTime = ratio * duration;
};
if (error) {
return (
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
<AlertIcon className="h-4 w-4" />
<span>{error}</span>
</div>
);
}
return (
<div className="mt-2 flex w-[280px] min-w-[280px] items-center gap-2 rounded-lg border border-line bg-surface-2 px-3 py-2">
<button
type="button"
onClick={onTogglePlay}
disabled={!blobUrl}
aria-label={playing ? 'Pause' : 'Wiedergabe'}
title={playing ? 'Pause' : 'Wiedergabe'}
className="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
>
{!blobUrl ? (
<SpinnerIcon className="h-4 w-4" />
) : playing ? (
<PauseGlyph />
) : (
<PlayGlyph />
)}
</button>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div
role="slider"
aria-label="Position"
aria-valuemin={0}
aria-valuemax={Math.max(1, Math.floor(duration))}
aria-valuenow={Math.floor(position)}
tabIndex={0}
onClick={onSeek}
className="flex h-7 cursor-pointer items-center gap-[2px]"
>
{visiblePeaks.map((v, i) => {
const playedRatio = (i + 0.5) / BAR_COUNT;
const played = playedRatio <= progress;
const h = Math.max(2, Math.round(v * 22));
return (
<span
key={i}
style={{ height: h + 'px' }}
className={
'w-[3px] rounded-full ' +
(played ? 'bg-accent' : 'bg-fg-muted/40')
}
/>
);
})}
</div>
<div className="flex items-center justify-between text-[10px] tabular-nums text-fg-muted">
<span className="inline-flex items-center gap-1">
<MicIcon className="h-3 w-3" />
<span>{formatSec(playing ? position : duration)}</span>
</span>
</div>
</div>
{blobUrl && (
<audio
ref={audioRef}
src={blobUrl}
preload="metadata"
onLoadedMetadata={(e) => {
// Some webm/opus blobs report Infinity until first seek (Chrome
// bug). Force a seek to flush real duration.
const el = e.currentTarget;
if (!Number.isFinite(el.duration)) {
el.currentTime = 1e9;
setTimeout(() => {
el.currentTime = 0;
}, 0);
} else if (duration === 0) {
setDuration(el.duration);
}
}}
onDurationChange={(e) => {
const d = e.currentTarget.duration;
if (Number.isFinite(d) && d > 0) setDuration(d);
}}
onTimeUpdate={(e) => setPosition(e.currentTarget.currentTime)}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => {
setPlaying(false);
setPosition(0);
}}
className="hidden"
>
<track kind="captions" />
</audio>
)}
</div>
);
}
function PlayGlyph() {
return (
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
<path d="M5 3.5l8 4.5-8 4.5z" />
</svg>
);
}
function PauseGlyph() {
return (
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
<rect x="4" y="3" width="3" height="10" rx="1" />
<rect x="9" y="3" width="3" height="10" rx="1" />
</svg>
);
}
function formatSec(sec: number): string {
if (!Number.isFinite(sec) || sec < 0) sec = 0;
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return m + ':' + s.toString().padStart(2, '0');
}
@@ -1,7 +1,7 @@
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { exportDeviceBackup } from '../lib/deviceBackup';
import { type BackupBundle, exportDeviceBackupWithRecovery } from '../lib/deviceBackup';
import { AlertIcon, CopyIcon, LockIcon, ShieldIcon, SpinnerIcon, XIcon } from './icons';
interface Props {
@@ -22,8 +22,9 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
const [confirm, setConfirm] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [backup, setBackup] = useState<string | null>(null);
const [bundle, setBundle] = useState<BackupBundle | null>(null);
const [copied, setCopied] = useState(false);
const [copiedRecovery, setCopiedRecovery] = useState(false);
const canGenerate = useMemo(() => {
return passphrase.length >= 8 && passphrase === confirm && !busy;
@@ -32,9 +33,10 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
const reset = useCallback(() => {
setPassphrase('');
setConfirm('');
setBackup(null);
setBundle(null);
setError(null);
setCopied(false);
setCopiedRecovery(false);
}, []);
const handleClose = useCallback(() => {
@@ -47,8 +49,13 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
setBusy(true);
setError(null);
try {
const str = await exportDeviceBackup({ userId, deviceId, privateKey, passphrase });
setBackup(str);
const b = await exportDeviceBackupWithRecovery({
userId,
deviceId,
privateKey,
passphrase,
});
setBundle(b);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -56,27 +63,39 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
}
}, [canGenerate, userId, deviceId, privateKey, passphrase]);
const handleCopy = useCallback(async () => {
if (!backup) return;
const copyText = async (text: string, marker: 'main' | 'recovery'): Promise<void> => {
try {
await navigator.clipboard.writeText(backup);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
await navigator.clipboard.writeText(text);
if (marker === 'main') {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} else {
setCopiedRecovery(true);
window.setTimeout(() => setCopiedRecovery(false), 1500);
}
} catch {
/* fall back — user can select manually */
}
}, [backup]);
};
const handleDownload = useCallback(() => {
if (!backup) return;
const blob = new Blob([backup], { type: 'text/plain;charset=utf-8' });
if (!bundle) return;
const text =
'=== Passphrase backup ===\n' +
bundle.passphraseBackup +
'\n\n=== Recovery code ===\n' +
bundle.recoveryCode +
'\n\n=== Recovery backup (use with the recovery code) ===\n' +
bundle.recoveryBackup +
'\n';
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chatapp-device-backup-${deviceId.slice(0, 8)}.txt`;
a.click();
URL.revokeObjectURL(url);
}, [backup, deviceId]);
}, [bundle, deviceId]);
if (!open) return null;
@@ -110,7 +129,7 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
</header>
<div className="flex-1 overflow-y-auto p-5">
{!backup ? (
{!bundle ? (
<>
<p className="text-sm text-fg-muted">
{t('app:backup.export_explainer', {
@@ -183,40 +202,76 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
</span>
</div>
</div>
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
Backup-String
</label>
<textarea
readOnly
value={backup}
rows={8}
value={bundle.passphraseBackup}
rows={6}
onFocus={(e) => e.currentTarget.select()}
className="mt-3 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
className="mt-1 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
/>
<div className="mt-3 flex gap-2">
<div className="mt-2 flex gap-2">
<button
type="button"
onClick={() => void handleCopy()}
onClick={() => void copyText(bundle.passphraseBackup, 'main')}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
>
<CopyIcon className="h-4 w-4" />
<span>
{copied
? t('app:backup.copied', { defaultValue: 'Kopiert!' })
: t('app:backup.copy', { defaultValue: 'Kopieren' })}
</span>
<span>{copied ? 'Kopiert!' : 'Kopieren'}</span>
</button>
<button
type="button"
onClick={handleDownload}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
>
{t('app:backup.download', { defaultValue: 'Als Datei speichern' })}
Als Datei speichern (alles)
</button>
</div>
<div className="mt-5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
<div className="flex items-start gap-2">
<ShieldIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold text-amber-700 dark:text-amber-200">
Recovery-Code (Passphrase vergessen?)
</p>
<p className="mt-0.5 text-[11px] text-amber-700/80 dark:text-amber-200/80">
Code separat aufbewahren. Mit dem Recovery-Backup unten lässt sich der Schlüssel
ohne Passphrase wiederherstellen.
</p>
<p className="mt-2 select-all rounded bg-surface-3 px-2 py-1.5 font-mono text-sm tracking-widest text-fg">
{bundle.recoveryCode}
</p>
</div>
</div>
</div>
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
Recovery-Backup-String
</label>
<textarea
readOnly
value={bundle.recoveryBackup}
rows={6}
onFocus={(e) => e.currentTarget.select()}
className="mt-1 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
/>
<button
type="button"
onClick={() => void copyText(bundle.recoveryBackup, 'recovery')}
className="mt-2 inline-flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
>
<CopyIcon className="h-4 w-4" />
<span>{copiedRecovery ? 'Kopiert!' : 'Recovery-Backup kopieren'}</span>
</button>
</>
)}
</div>
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
{!backup ? (
{!bundle ? (
<>
<button
type="button"
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
import { CrownIcon, HeadphonesOffIcon, LockIcon, MicOffIcon, MonitorShareIcon } from './icons';
import { CrownIcon, HeadphonesOffIcon, LockIcon, MicOffIcon } from './icons';
export type AvatarColorKey = 'violet' | 'amber' | 'rose' | 'teal';
@@ -48,7 +48,6 @@ export interface ParticipantTileProps {
* ever carries a truthy value. */
deafened: boolean;
speaking: boolean;
sharing: boolean;
video: boolean;
e2ee: boolean;
/** MediaStreamTrack for the participant's active camera, when `video` is
@@ -57,7 +56,7 @@ export interface ParticipantTileProps {
size?: 'default' | 'small';
focused?: boolean;
onClick?: () => void;
onOpenScreenShare?: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
}
export function CallParticipantTile(props: ParticipantTileProps) {
@@ -67,13 +66,12 @@ export function CallParticipantTile(props: ParticipantTileProps) {
muted,
deafened,
speaking,
sharing,
video,
e2ee,
size = 'default',
focused = false,
onClick,
onOpenScreenShare,
onContextMenu,
} = props;
const small = size === 'small';
@@ -86,6 +84,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
return (
<div
onClick={onClick}
onContextMenu={onContextMenu}
className={
'relative flex flex-col overflow-hidden rounded-[14px] border bg-surface-3 transition ' +
(onClick ? 'cursor-pointer ' : '') +
@@ -93,13 +92,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
(small ? ' min-w-[140px]' : '')
}
>
{sharing ? (
<ScreenshareStub
displayName={displayName}
small={small}
onOpen={onOpenScreenShare}
/>
) : video ? (
{video ? (
<VideoStub {...props} small={small} />
) : (
<AudioContent {...props} small={small} />
@@ -150,15 +143,6 @@ export function CallParticipantTile(props: ParticipantTileProps) {
<HeadphonesOffIcon className="h-3 w-3" />
</span>
)}
{sharing && (
<span
aria-label="Teilt Bildschirm"
title="Teilt Bildschirm"
className="flex h-5 w-5 items-center justify-center rounded-md bg-emerald-500/80 text-white"
>
<MonitorShareIcon className="h-3 w-3" />
</span>
)}
</div>
</div>
</div>
@@ -276,49 +260,3 @@ function VideoStub({
);
}
interface ScreenshareStubProps {
displayName: string;
small: boolean;
onOpen?: (() => void) | undefined;
}
// Fake browser window placeholder — click to open the real <video> viewer.
// Matches the design spec's "screenshare-stub" look.
function ScreenshareStub({ displayName, small, onOpen }: ScreenshareStubProps) {
return (
<button
type="button"
onClick={
onOpen
? (e) => {
e.stopPropagation();
onOpen();
}
: undefined
}
aria-label={`${displayName} teilt Bildschirm`}
className="relative flex min-h-0 flex-1 cursor-pointer items-center justify-center bg-ink-900 p-3 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<div className="h-[85%] w-[90%] overflow-hidden rounded-lg border border-white/10 bg-ink-700">
<div className="flex h-[20px] items-center gap-1 bg-ink-600 px-2">
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
</div>
<div className={'flex flex-col gap-2 ' + (small ? 'gap-[3px] p-1.5' : 'p-3.5')}>
<div className={'w-[60%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
<div className={'w-[80%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
<div className={'w-[40%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
<div className={'my-1 rounded ' + (small ? 'h-[14px]' : 'h-10') + ' bg-accent/30'} />
<div className={'w-[70%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
</div>
</div>
{!small && (
<div className="absolute left-3 top-3 flex items-center gap-1.5 glass-chip rounded-lg px-2.5 py-1 text-[10px] font-medium">
<MonitorShareIcon className="h-3 w-3" />
<span>{displayName} teilt Bildschirm</span>
</div>
)}
</button>
);
}
+37 -7
View File
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next';
import {
decodePrivateKeyFromBackup,
importDeviceBackup,
normalizeRecoveryCode,
} from '../lib/deviceBackup';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
@@ -28,6 +29,7 @@ export function DeviceRestore({ userId, onRestored }: Props) {
const { t } = useTranslation(['app', 'errors']);
const [backup, setBackup] = useState('');
const [passphrase, setPassphrase] = useState('');
const [mode, setMode] = useState<'passphrase' | 'recovery'>('passphrase');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -39,7 +41,9 @@ export function DeviceRestore({ userId, onRestored }: Props) {
setError(null);
let privateKey: Uint8Array | null = null;
try {
const payload = await importDeviceBackup(backup.trim(), passphrase);
const secret =
mode === 'recovery' ? normalizeRecoveryCode(passphrase) : passphrase;
const payload = await importDeviceBackup(backup.trim(), secret);
privateKey = decodePrivateKeyFromBackup(payload);
const device = await restoreDeviceFromServerRecord({
@@ -63,7 +67,7 @@ export function DeviceRestore({ userId, onRestored }: Props) {
setBusy(false);
}
},
[backup, passphrase, userId, onRestored, busy],
[backup, passphrase, mode, userId, onRestored, busy],
);
return (
@@ -102,16 +106,42 @@ export function DeviceRestore({ userId, onRestored }: Props) {
/>
</div>
<div className="space-y-1">
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
{t('app:backup.passphrase', { defaultValue: 'Passphrase' })}
</label>
<div className="flex items-center justify-between">
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
{mode === 'passphrase' ? 'Passphrase' : 'Recovery-Code'}
</label>
<button
type="button"
onClick={() => {
setMode((m) => (m === 'passphrase' ? 'recovery' : 'passphrase'));
setPassphrase('');
setError(null);
}}
className="cursor-pointer text-[11px] font-semibold text-brand-300 hover:underline"
>
{mode === 'passphrase'
? 'Passphrase vergessen? Recovery-Code nutzen'
: 'Stattdessen Passphrase eingeben'}
</button>
</div>
<input
type="password"
type={mode === 'passphrase' ? 'password' : 'text'}
required
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
placeholder={mode === 'recovery' ? 'XXXXXX-XXXXXX-XXXXXX-XXXXXX' : ''}
spellCheck={false}
autoComplete={mode === 'recovery' ? 'off' : 'current-password'}
className={
'w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40 ' +
(mode === 'recovery' ? 'font-mono tracking-widest' : '')
}
/>
{mode === 'recovery' && (
<p className="text-[11px] text-neutral-500">
Stattdessen den Recovery-Backup-String oben einfügen.
</p>
)}
</div>
</div>
+149 -24
View File
@@ -1,7 +1,7 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import type { RemoteParticipant, Room } from 'livekit-client';
import { Track } from 'livekit-client';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
@@ -15,6 +15,7 @@ import { useActiveSpeakers } from '../lib/useActiveSpeakers';
import { CallControls } from './CallControls';
import { CallParticipantTile } from './CallParticipantTile';
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
import { ScreenShareDialog } from './ScreenShareDialog';
import { ScreenShareViewer } from './ScreenShareViewer';
@@ -76,6 +77,21 @@ export function InCallPanel({ conversation }: Props) {
const myId = session?.user.id ?? null;
const activeSpeakers = useActiveSpeakers(room);
const [shareDialogOpen, setShareDialogOpen] = useState(false);
const [volumeMenu, setVolumeMenu] = useState<
{ userId: string; displayName: string; x: number; y: number } | null
>(null);
const openVolumeMenu = (tile: Tile, e: React.MouseEvent) => {
if (tile.self) return;
if (tile.kind !== 'user') return;
e.preventDefault();
setVolumeMenu({
userId: tile.userId,
displayName: tile.displayName,
x: e.clientX,
y: e.clientY,
});
};
const active =
(state.kind === 'connected' ||
@@ -142,26 +158,46 @@ export function InCallPanel({ conversation }: Props) {
);
if (callMode === 'fullscreen') {
// In fullscreen, a "manual focus" = user explicitly picked someone OR
// someone is sharing their screen. Without that we show an even grid of
// all participants (Discord default). Clicking a tile switches to the
// big-speaker + thumbnail-strip layout.
const hasFocus = focusedId !== null || screenTile !== undefined;
// In fullscreen, a "manual focus" = user explicitly picked someone, OR
// someone is sharing a screen, OR exactly one non-self speaker is talking
// (auto-promote). Without that we show an even grid of all participants
// (Discord default). Clicking a tile switches to the big-speaker layout.
const speakingNonSelf = tiles.filter(
(t: Tile) => activeSpeakers.has(t.userId) && !t.self && t.kind === 'user',
);
const autoSpeaker =
focusedId === null && screenTile === undefined && speakingNonSelf.length === 1
? speakingNonSelf[0]
: undefined;
const hasFocus = focusedId !== null || screenTile !== undefined || autoSpeaker !== undefined;
const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined;
return (
<FullscreenCall
tiles={tiles}
speaker={hasFocus ? speaker : undefined}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversation.members}
activeSpeakers={activeSpeakers}
e2ee={isE2EEActive}
onExit={() => setCallMode('grid')}
onFocusTile={(id) => {
// Toggle: click the already-focused tile to return to grid.
setFocusedId(focusedId === id ? null : id);
}}
controls={controls}
/>
<>
<FullscreenCall
tiles={tiles}
speaker={effectiveSpeaker}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversation.members}
activeSpeakers={activeSpeakers}
e2ee={isE2EEActive}
onExit={() => setCallMode('grid')}
onFocusTile={(id) => {
// Toggle: click the already-focused tile to return to grid.
setFocusedId(focusedId === id ? null : id);
}}
onTileContextMenu={openVolumeMenu}
controls={controls}
/>
{volumeMenu && (
<ParticipantVolumeMenu
userId={volumeMenu.userId}
displayName={volumeMenu.displayName}
x={volumeMenu.x}
y={volumeMenu.y}
onClose={() => setVolumeMenu(null)}
/>
)}
</>
);
}
@@ -219,6 +255,7 @@ export function InCallPanel({ conversation }: Props) {
setFocusedId(id);
if (callMode === 'grid') setCallMode('focus');
}}
onTileContextMenu={openVolumeMenu}
compact
/>
@@ -233,6 +270,16 @@ export function InCallPanel({ conversation }: Props) {
await startScreenShare(opts);
}}
/>
{volumeMenu && (
<ParticipantVolumeMenu
userId={volumeMenu.userId}
displayName={volumeMenu.displayName}
x={volumeMenu.x}
y={volumeMenu.y}
onClose={() => setVolumeMenu(null)}
/>
)}
</section>
);
}
@@ -447,6 +494,7 @@ interface StageProps {
}[];
conversationMembers: ConversationSummary['members'];
onFocusTile: (id: string) => void;
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
compact?: boolean;
}
@@ -462,6 +510,7 @@ function TileRender({
size,
focused,
onClick,
onContextMenu,
}: {
tile: Tile;
activeSpeakers: Set<string>;
@@ -471,6 +520,7 @@ function TileRender({
size?: 'default' | 'small';
focused?: boolean;
onClick?: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
}): JSX.Element {
if (tile.kind === 'screen') {
if (tile.self) {
@@ -519,13 +569,13 @@ function TileRender({
muted={tile.muted}
deafened={tile.deafened}
speaking={activeSpeakers.has(tile.userId)}
sharing={false}
video={tile.video}
videoTrack={tile.videoTrack}
e2ee={e2ee}
{...(size ? { size } : {})}
{...(focused ? { focused } : {})}
{...(onClick ? { onClick } : {})}
{...(onContextMenu ? { onContextMenu } : {})}
/>
);
}
@@ -539,6 +589,7 @@ function CallStage({
remoteScreenShares,
conversationMembers,
onFocusTile,
onTileContextMenu,
compact = false,
}: StageProps) {
if (mode === 'focus' && speaker) {
@@ -569,6 +620,9 @@ function CallStage({
conversationMembers={conversationMembers}
size="small"
onClick={() => onFocusTile(p.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
: {})}
/>
</div>
))}
@@ -592,6 +646,9 @@ function CallStage({
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
onClick={() => onFocusTile(p.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
: {})}
/>
</div>
))}
@@ -627,6 +684,8 @@ function FocusedTile({
);
}
const GRID_PAGE_SIZE = 12;
function gridColsFor(n: number): string {
// Explicit `grid-rows-*` so cells get a defined height (1fr of available
// space). Without this, implicit rows default to auto → they size to
@@ -636,7 +695,22 @@ function gridColsFor(n: number): string {
if (n === 2) return 'grid-cols-2 grid-rows-1';
if (n === 3) return 'grid-cols-3 grid-rows-1';
if (n === 4) return 'grid-cols-2 grid-rows-2';
return 'grid-cols-3 grid-rows-2';
if (n <= 6) return 'grid-cols-3 grid-rows-2';
if (n <= 9) return 'grid-cols-3 grid-rows-3';
return 'grid-cols-4 grid-rows-3';
}
// Promote self + active speakers to the front of the tile list. Stable
// otherwise. Used by both pagination (so page 1 always carries the most
// "useful" tiles) and active-speaker promotion in fullscreen.
function prioritizeTiles(tiles: Tile[], activeSpeakers: Set<string>): Tile[] {
const score = (t: Tile): number => {
if (t.self) return 3;
if (activeSpeakers.has(t.userId)) return 2;
if (t.kind === 'screen') return 1;
return 0;
};
return [...tiles].sort((a, b) => score(b) - score(a));
}
// ---------------------------------------------------------------------------
@@ -652,6 +726,7 @@ interface FullscreenProps {
e2ee: boolean;
onExit: () => void;
onFocusTile: (id: string) => void;
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
controls: React.ReactNode;
}
@@ -664,9 +739,11 @@ function FullscreenCall({
e2ee,
onExit: _onExit,
onFocusTile,
onTileContextMenu,
controls,
}: FullscreenProps) {
const [hintGone, setHintGone] = useState(false);
const [page, setPage] = useState(0);
useEffect(() => {
const id = window.setTimeout(() => setHintGone(true), 3500);
return () => window.clearTimeout(id);
@@ -674,7 +751,23 @@ function FullscreenCall({
const hasFocus = speaker !== undefined;
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
const gridClass = gridColsFor(tiles.length);
// Active-speaker reorder + paginate. When more than GRID_PAGE_SIZE tiles
// exist, slice them into pages. Reset to page 0 if the page count drops
// below the current page (someone left).
const sortedGridTiles = useMemo(
() => prioritizeTiles(tiles, activeSpeakers),
[tiles, activeSpeakers],
);
const pageCount = Math.max(1, Math.ceil(sortedGridTiles.length / GRID_PAGE_SIZE));
useEffect(() => {
if (page >= pageCount) setPage(0);
}, [pageCount, page]);
const visibleTiles = sortedGridTiles.slice(
page * GRID_PAGE_SIZE,
(page + 1) * GRID_PAGE_SIZE,
);
const gridClass = gridColsFor(visibleTiles.length);
return (
<div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface">
@@ -695,6 +788,9 @@ function FullscreenCall({
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
focused
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker!, e) }
: {})}
/>
</div>
{others.length > 0 && (
@@ -712,6 +808,9 @@ function FullscreenCall({
conversationMembers={conversationMembers}
size="small"
onClick={() => onFocusTile(p.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
: {})}
/>
</div>
))}
@@ -721,7 +820,7 @@ function FullscreenCall({
) : (
<div className="min-h-0 flex-1 p-4">
<div className={'grid h-full gap-2 ' + gridClass}>
{tiles.map((p) => (
{visibleTiles.map((p) => (
<div key={p.id} className="min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full">
<TileRender
tile={p}
@@ -730,10 +829,36 @@ function FullscreenCall({
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
onClick={() => onFocusTile(p.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
: {})}
/>
</div>
))}
</div>
{pageCount > 1 && (
<div className="mt-2 flex items-center justify-center gap-3 text-xs text-fg-muted">
<button
type="button"
onClick={() => setPage((p) => (p === 0 ? pageCount - 1 : p - 1))}
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-1 hover:bg-surface-3"
aria-label="Vorherige Seite"
>
</button>
<span className="tabular-nums">
{page + 1} / {pageCount}
</span>
<button
type="button"
onClick={() => setPage((p) => (p + 1) % pageCount)}
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-1 hover:bg-surface-3"
aria-label="Nächste Seite"
>
</button>
</div>
)}
</div>
)}
</div>
+59 -9
View File
@@ -13,6 +13,7 @@ import { useAuth } from '../context/AuthContext';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import type { AggregatedReaction } from '../lib/useMessageReactions';
import { AttachmentAudio } from './AttachmentAudio';
import { AttachmentImage } from './AttachmentImage';
import { Avatar } from './Avatar';
import { ForwardIcon, PencilIcon, PhoneIcon, PhoneOffIcon, ReplyIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
@@ -40,6 +41,8 @@ interface Props {
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. */
@@ -63,6 +66,7 @@ export function MessageBubble({
reactions,
onToggleReaction,
showSeen = false,
deliveryState,
quoted = null,
onJumpToMessage,
onReply,
@@ -296,9 +300,13 @@ export function MessageBubble({
) : (
<>
{bodyText.length > 0 && <div>{bodyText}</div>}
{attachments.map((a) => (
<AttachmentImage key={a.id} handle={a} />
))}
{attachments.map((a) =>
a.mimeType.startsWith('audio/') ? (
<AttachmentAudio key={a.id} handle={a} />
) : (
<AttachmentImage key={a.id} handle={a} />
),
)}
</>
)}
<div
@@ -315,21 +323,24 @@ export function MessageBubble({
</div>
)}
{showSeen && mine && !editing && !message.deletedAt && (
<p className="mt-0.5 text-right text-[10px] text-fg-muted">
{t('app:chats.seen')}
</p>
{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
key={r.emoji}
// 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 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 ' +
'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')
@@ -499,6 +510,45 @@ function formatDuration(totalSec: number): string {
return m + ':' + s.toString().padStart(2, '0');
}
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,
@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import {
getParticipantVolume,
setParticipantVolume,
subscribeParticipantVolumes,
} from '../lib/participantVolumes';
interface Props {
userId: string;
displayName: string;
x: number;
y: number;
onClose: () => void;
}
const MENU_W = 240;
const MENU_H = 84;
export function ParticipantVolumeMenu({
userId,
displayName,
x,
y,
onClose,
}: Props) {
const [volume, setVolume] = useState<number>(() => getParticipantVolume(userId));
// Re-sync from store in case another menu instance changed the same user.
useEffect(() => subscribeParticipantVolumes(() => {
setVolume(getParticipantVolume(userId));
}), [userId]);
// Outside click + Esc to close.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const onDown = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (target?.closest('[data-volume-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 - MENU_W - 8);
const top = Math.min(Math.max(8, y), window.innerHeight - MENU_H - 8);
return createPortal(
<div
data-volume-menu
role="dialog"
aria-label={'Lautstärke ' + displayName}
style={{ left, top, width: MENU_W }}
className="fixed z-[80] rounded-xl border border-line bg-surface-2/95 p-3 shadow-xl backdrop-blur-md"
>
<div className="mb-2 flex items-center justify-between gap-2 text-xs">
<span className="truncate font-semibold text-fg">{displayName}</span>
<span className="tabular-nums text-fg-muted">
{Math.round(volume * 100)}%
</span>
</div>
<input
type="range"
min={0}
max={1}
step={0.01}
value={volume}
onChange={(e) => {
const v = Number(e.target.value);
setVolume(v);
setParticipantVolume(userId, v);
}}
aria-label={'Lautstärke ' + displayName}
className="w-full accent-accent"
/>
</div>,
document.body,
);
}
@@ -0,0 +1,252 @@
import { useEffect, useRef, useState } from 'react';
import { MicIcon, SpinnerIcon, XIcon } from './icons';
interface Props {
/** Called with the recorded audio file when the user confirms. The button
* ships as a single-attachment message, so the parent can feed it into
* the normal send flow. */
onComplete: (file: File) => Promise<void> | void;
disabled?: boolean;
}
// Single-button voice recorder. Click to start; shows an inline pill with
// elapsed time + stop + cancel while recording. On stop, hands a File to the
// parent. No in-place preview yet — we lean on the optimistic message bubble
// to appear once the parent sends.
const MAX_RECORD_SEC = 60;
export function VoiceRecorder({ onComplete, disabled = false }: Props) {
const [state, setState] = useState<'idle' | 'recording' | 'finalizing'>('idle');
const [elapsedSec, setElapsedSec] = useState(0);
const [level, setLevel] = useState(0);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const startedAtRef = useRef<number>(0);
const cancelledRef = useRef(false);
const audioCtxRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const rafRef = useRef<number>(0);
// Tick elapsed + auto-stop at MAX_RECORD_SEC.
useEffect(() => {
if (state !== 'recording') return;
const id = window.setInterval(() => {
const sec = Math.floor((Date.now() - startedAtRef.current) / 1000);
setElapsedSec(sec);
if (sec >= MAX_RECORD_SEC) {
const rec = recorderRef.current;
if (rec && rec.state !== 'inactive') rec.stop();
}
}, 200);
return () => {
window.clearInterval(id);
};
}, [state]);
// Live mic level meter via Web Audio AnalyserNode. RMS on 0..1, smoothed.
useEffect(() => {
if (state !== 'recording') return;
const analyser = analyserRef.current;
if (!analyser) return;
const buf = new Uint8Array(analyser.frequencyBinCount);
let cancelled = false;
const tick = () => {
if (cancelled) return;
analyser.getByteFrequencyData(buf as Uint8Array<ArrayBuffer>);
let sum = 0;
for (let i = 0; i < buf.length; i++) sum += buf[i]!;
setLevel(sum / (buf.length * 255));
rafRef.current = window.requestAnimationFrame(tick);
};
rafRef.current = window.requestAnimationFrame(tick);
return () => {
cancelled = true;
window.cancelAnimationFrame(rafRef.current);
};
}, [state]);
const stopStream = () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => t.stop());
streamRef.current = null;
}
if (audioCtxRef.current) {
void audioCtxRef.current.close().catch(() => {});
audioCtxRef.current = null;
}
analyserRef.current = null;
setLevel(0);
};
const start = async () => {
if (disabled) return;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
try {
const ctx = new (window.AudioContext ||
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
const source = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.4;
source.connect(analyser);
audioCtxRef.current = ctx;
analyserRef.current = analyser;
} catch {
/* Analyser is best-effort; recording still works without level meter. */
}
const mime = pickMime();
const rec = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
recorderRef.current = rec;
chunksRef.current = [];
cancelledRef.current = false;
rec.ondataavailable = (ev: BlobEvent) => {
if (ev.data && ev.data.size > 0) chunksRef.current.push(ev.data);
};
rec.onstop = () => {
const blob = new Blob(chunksRef.current, { type: rec.mimeType || 'audio/webm' });
chunksRef.current = [];
stopStream();
if (cancelledRef.current || blob.size === 0) {
setState('idle');
setElapsedSec(0);
return;
}
setState('finalizing');
const ext = extFor(blob.type);
const filename = 'voice-' + new Date().toISOString().replace(/[:.]/g, '-') + '.' + ext;
const file = new File([blob], filename, { type: blob.type });
void Promise.resolve(onComplete(file)).finally(() => {
setState('idle');
setElapsedSec(0);
});
};
rec.start();
startedAtRef.current = Date.now();
setElapsedSec(0);
setState('recording');
} catch (err: unknown) {
stopStream();
// Surface device-permission denials or capture failures via console;
// the composer doesn't have space for inline errors here and the
// browser already shows a system-level permission prompt.
console.error('VoiceRecorder.start failed', err);
}
};
const confirm = () => {
const rec = recorderRef.current;
if (!rec) return;
if (rec.state !== 'inactive') rec.stop();
};
const cancel = () => {
cancelledRef.current = true;
const rec = recorderRef.current;
if (rec && rec.state !== 'inactive') rec.stop();
else {
stopStream();
setState('idle');
setElapsedSec(0);
}
};
if (state === 'idle') {
return (
<button
type="button"
onClick={() => void start()}
disabled={disabled}
aria-label="Sprachnachricht aufnehmen"
title="Sprachnachricht aufnehmen"
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-surface-2 text-fg transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
>
<MicIcon className="h-4 w-4" />
</button>
);
}
if (state === 'finalizing') {
return (
<div className="inline-flex h-11 items-center gap-2 rounded-lg bg-surface-2 px-3 text-xs text-fg-muted">
<SpinnerIcon className="h-4 w-4" />
<span>Wird gesendet</span>
</div>
);
}
// Visual level: scale from 0..1 → 0..100% width. Floor at 4% so the bar
// stays visible when silent.
const levelPct = Math.max(4, Math.min(100, Math.round(level * 180)));
const remaining = Math.max(0, MAX_RECORD_SEC - elapsedSec);
return (
<div className="inline-flex h-11 items-center gap-2 rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 text-xs font-semibold text-rose-700 dark:text-rose-200">
<span className="h-2 w-2 animate-pulse rounded-full bg-rose-500" />
<span className="tabular-nums">{formatTime(elapsedSec)}</span>
<div
aria-hidden="true"
className="relative h-1.5 w-20 overflow-hidden rounded-full bg-rose-500/20"
>
<span
className="absolute inset-y-0 left-0 rounded-full bg-rose-500 transition-[width] duration-75"
style={{ width: levelPct + '%' }}
/>
</div>
<span className="tabular-nums text-[10px] text-rose-700/70 dark:text-rose-200/70">
{remaining}s
</span>
<button
type="button"
onClick={cancel}
aria-label="Aufnahme abbrechen"
title="Abbrechen"
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
>
<XIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={confirm}
aria-label="Aufnahme senden"
title="Senden"
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md bg-accent text-accent-fg transition hover:brightness-110"
>
<MicIcon className="h-3.5 w-3.5" />
</button>
</div>
);
}
function pickMime(): string | null {
const candidates = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
'audio/mp4',
];
for (const c of candidates) {
if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(c)) return c;
}
return null;
}
function extFor(mime: string): string {
if (mime.includes('webm')) return 'webm';
if (mime.includes('ogg')) return 'ogg';
if (mime.includes('mp4')) return 'm4a';
return 'bin';
}
function formatTime(sec: number): string {
const m = Math.floor(sec / 60);
const s = sec % 60;
return m + ':' + s.toString().padStart(2, '0');
}