import { updateOwnProfile } from '@chat-app/shared/auth'; import { changeLocale as changeLocaleI18n, SUPPORTED_LOCALES, type SupportedLocale, } from '@chat-app/shared/i18n'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Avatar } from '../components/Avatar'; import { BackupExportDialog } from '../components/BackupExportDialog'; import { LockIcon } from '../components/icons'; import { useAuth } from '../context/AuthContext'; import { useCall } from '../context/CallContext'; import { loadDevicePrivateKey } from '@chat-app/shared/auth'; import { deleteAvatarObject, uploadAvatar } from '../lib/avatarUpload'; import { devLocalSecretStore } from '../lib/secretStore'; import { getPttSettings, keyCodeToLabel, type PttSettings, subscribePttSettings, updatePttSettings, } from '../lib/pttSettings'; import { AUDIO_QUALITY_ORDER, type AudioQuality, type AudioSettings, getAudioQualityParams, getAudioSettings, subscribeAudioSettings, updateAudioSettings, } from '../lib/audioSettings'; import { type CallE2EESettings, getCallE2EESettings, isE2EESupported, subscribeCallE2EESettings, updateCallE2EESettings, } from '../lib/callE2EE'; import { getPresetParams, getScreenShareSettings, PRESET_ORDER, type ScreenSharePreset, type ScreenShareSettings, subscribeScreenShareSettings, updateScreenShareSettings, } from '../lib/screenShareSettings'; import { supabase } from '../lib/supabase'; const LOCALE_LABELS: Record = { en: 'English', de: 'Deutsch', }; export function SettingsPage() { const { t, i18n } = useTranslation(['app', 'common', 'auth']); const { profile, device, refreshProfile, signOut } = useAuth(); const [busy, setBusy] = useState(false); async function patchProfile(patch: Parameters[1]) { setBusy(true); try { await updateOwnProfile(supabase, patch); await refreshProfile(); } catch (err: unknown) { console.error('updateProfile failed', err); } finally { setBusy(false); } } async function handleLocaleChange(locale: SupportedLocale) { await changeLocaleI18n(locale); void patchProfile({ locale }); } return (

{t('app:settings.title')}

{/* Account */}
{/* Appearance */}
{SUPPORTED_LOCALES.map((locale) => { const active = (i18n.resolvedLanguage ?? i18n.language) === locale; return ( ); })}
{/* Privacy */}
void patchProfile({ showReadReceipts: v })} /> void patchProfile({ allowDmsFromStrangers: v })} />
{/* Voice / Push-to-Talk + Audio Quality + E2EE */}
{/* Screen-share quality */}
{/* Devices */}
{device && (
{t('app:settings.this_device')}
)}
{/* Danger zone */}
); } function PttControls() { const { t } = useTranslation(['app']); const [ptt, setPtt] = useState(() => getPttSettings()); const [capturing, setCapturing] = useState(false); useEffect(() => { return subscribePttSettings(setPtt); }, []); useEffect(() => { if (!capturing) return; const onKey = (e: KeyboardEvent) => { e.preventDefault(); if (e.code === 'Escape') { setCapturing(false); return; } updatePttSettings({ key: e.code, keyLabel: keyCodeToLabel(e.code) }); setCapturing(false); }; window.addEventListener('keydown', onKey, { capture: true }); return () => window.removeEventListener('keydown', onKey, { capture: true }); }, [capturing]); return ( <> updatePttSettings({ enabled: v })} /> ); } function CallE2EEControls() { const { t } = useTranslation(['app']); const [cfg, setCfg] = useState(() => getCallE2EESettings()); const [supported] = useState(() => isE2EESupported()); useEffect(() => subscribeCallE2EESettings(setCfg), []); return ( <> updateCallE2EESettings({ enabled: v })} /> ); } function AudioQualityControls() { const { t } = useTranslation(['app']); const [cfg, setCfg] = useState(() => getAudioSettings()); useEffect(() => subscribeAudioSettings(setCfg), []); const params = getAudioQualityParams(cfg.quality); const labels: Record = { voice: t('app:settings.audio_voice', { defaultValue: 'Sprache (Empfohlen)' }), hifi: t('app:settings.audio_hifi', { defaultValue: 'HiFi / Musik' }), }; return ( <>
{AUDIO_QUALITY_ORDER.map((q) => { const active = cfg.quality === q; return ( ); })}

{cfg.quality === 'hifi' ? t('app:settings.audio_hifi_hint', { defaultValue: 'Stereo 510 kbps Opus ohne Noise-Suppression/Echo-Cancellation — bester Musik/Broadcast-Sound. Erfordert ruhige Umgebung.', }) : t('app:settings.audio_voice_hint', { defaultValue: 'Mono 48 kbps mit Noise-Suppression, Echo-Cancellation und Auto-Gain. Optimiert für Sprache im Raum.', })}

); } interface AvatarControlsProps { patchProfile: (patch: Parameters[1]) => Promise; busy: boolean; } function AvatarControls({ patchProfile, busy }: AvatarControlsProps) { const { t } = useTranslation(['app']); const { profile } = useAuth(); const inputRef = useRef(null); const [uploading, setUploading] = useState(false); const [error, setError] = useState(null); const userId = profile?.userId; const url = profile?.avatarUrl ?? null; async function handleFile(file: File) { if (!userId) return; setError(null); setUploading(true); try { const newUrl = await uploadAvatar(userId, file); const oldUrl = url; await patchProfile({ avatarUrl: newUrl }); if (oldUrl) { // Best-effort cleanup of the previous file (don't block on it). void deleteAvatarObject(oldUrl).catch(() => { /* ignore */ }); } } catch (err: unknown) { setError(err instanceof Error ? err.message : 'upload failed'); } finally { setUploading(false); if (inputRef.current) inputRef.current.value = ''; } } async function handleRemove() { if (!userId || !url) return; setError(null); setUploading(true); try { await patchProfile({ avatarUrl: null }); void deleteAvatarObject(url).catch(() => undefined); } catch (err: unknown) { setError(err instanceof Error ? err.message : 'remove failed'); } finally { setUploading(false); } } return (
{t('app:settings.avatar', { defaultValue: 'Avatar' })}
{t('app:settings.avatar_hint', { defaultValue: 'Quadratisch, max 512×512, WebP. Sichtbar für deine Freunde.', })}
{error && (
{error}
)}
{ const f = e.target.files?.[0]; if (f) void handleFile(f); }} /> {url && ( )}
); } function DeviceKeyBackupControls() { const { t } = useTranslation(['app']); const { profile, device } = useAuth(); const [open, setOpen] = useState(false); const [privateKey, setPrivateKey] = useState(null); const [err, setErr] = useState(null); const canRun = !!profile?.userId && !!device?.id; async function handleOpen() { if (!canRun) return; setErr(null); try { const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id); if (!priv) throw new Error(t('app:backup.no_key_here', { defaultValue: 'Kein Geräteschlüssel auf dieser Installation.', })); setPrivateKey(priv); setOpen(true); } catch (e: unknown) { setErr(e instanceof Error ? e.message : 'failed to load key'); } } function handleClose() { setOpen(false); if (privateKey) { for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0; } setPrivateKey(null); } return (
{t('app:settings.device_key_backup', { defaultValue: 'Geräteschlüssel-Backup' })}

{t('app:settings.device_key_backup_hint_v2', { defaultValue: 'Backup deines Geräteschlüssels. Ohne Backup kein Zugriff auf alte Nachrichten wenn Gerät oder Browser-Storage verloren geht. Wiederherstellung passiert im Gerät-Einrichtungsbildschirm.', })}

{err && (

{err}

)} {open && profile && device && privateKey && ( )}
); } function ScreenShareControls() { const { t } = useTranslation(['app']); const [cfg, setCfg] = useState(() => getScreenShareSettings()); useEffect(() => subscribeScreenShareSettings(setCfg), []); const params = getPresetParams(cfg.preset); return ( <>

{t('app:settings.screen_share_hint', { defaultValue: 'WebRTC passt Bitrate + Auflösung dynamisch an die Netzwerkqualität an (SVC/VP9). Die Werte sind Obergrenzen. Änderungen greifen beim nächsten Call.', })}

); } function Stat({ label, value }: { label: string; value: string }) { return (
{label}
{value}
); } function formatBitrate(kbps: number): string { if (kbps >= 1000) { return (kbps / 1000).toFixed(kbps % 1000 === 0 ? 0 : 1) + ' Mbps'; } return kbps + ' kbps'; } function Section({ title, children }: { title: string; children: React.ReactNode }) { return (

{title}

{children}
); } function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) { return (
{label}
{value}
); } function SettingRow({ label, children }: { label: string; children: React.ReactNode }) { return (
{label} {children}
); } function Toggle({ label, hint, checked, disabled, onChange, }: { label: string; hint?: string; checked: boolean; disabled?: boolean; onChange: (next: boolean) => void; }) { return ( ); } // --------------------------------------------------------------------------- // Audio device selection — persisted input + output deviceIds, hot-swap on // active calls. Output swap uses HTMLMediaElement.setSinkId on our attached //