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