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 { AtIcon, BellIcon, LockIcon, MicIcon, MonitorShareIcon, MusicIcon, ShieldIcon, SignOutIcon, SunIcon, UsersIcon, } from '../components/icons'; import { MicTestSection } from '../components/MicTestSection'; import { NotificationSoundSettings } from '../components/NotificationSoundSettings'; import { RingtoneSettings } from '../components/RingtoneSettings'; import { DeviceListTab } from '../components/settings/DeviceListTab'; import { SecurityCenter } from '../components/SecurityCenter'; import { SoundboardSettings } from '../components/SoundboardSettings'; import { useAuth } from '../context/AuthContext'; import { useCall } from '../context/CallContext'; import { useTheme } from '../context/ThemeContext'; import { isAutoStartEnabled, setAutoStart } from '../lib/autoStart'; import { AVATAR_TARGET_DIM, deleteAvatarObject, uploadAvatarBlob, } from '../lib/avatarUpload'; import { BANNER_MAX_INPUT_BYTES, BANNER_TARGET_HEIGHT, BANNER_TARGET_WIDTH, deleteBannerObject, uploadBannerBlob, } from '../lib/bannerUpload'; import { ImageCropDialog } from '../components/ImageCropDialog'; import { Lightbox } from '../components/Lightbox'; import { getPttSettings, keyCodeToLabel, type PttSettings, subscribePttSettings, updatePttSettings, } from '../lib/pttSettings'; import { getVoiceHotkeys, subscribeVoiceHotkeys, updateVoiceHotkey, type VoiceHotkeyKind, type VoiceHotkeys, } from '../lib/voiceHotkeys'; 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, 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 }); } // Tab pattern (macOS / Discord / GitHub style): the sidebar selects ONE // section and only that section renders. activeTab is the single source of // truth — no IntersectionObserver to drift, no smooth-scroll, no anchor-link // routing conflict with HashRouter. type TabId = | 'profile' | 'appearance' | 'privacy' | 'notifications' | 'voice' | 'screen-share' | 'soundboard' | 'security' | 'devices' | 'account'; const tabs: Array<{ id: TabId; label: string; Icon: typeof UsersIcon }> = [ { id: 'profile', label: t('app:settings.nav_profile', { defaultValue: 'Profil' }), Icon: UsersIcon }, { id: 'appearance', label: t('app:settings.nav_appearance', { defaultValue: 'Erscheinungsbild' }), Icon: SunIcon }, { id: 'privacy', label: t('app:settings.nav_privacy', { defaultValue: 'Privatsphäre' }), Icon: ShieldIcon }, { id: 'notifications', label: t('app:settings.nav_notifications', { defaultValue: 'Benachrichtigungen' }), Icon: BellIcon }, { id: 'voice', label: t('app:settings.nav_voice', { defaultValue: 'Sprache & Anrufe' }), Icon: MicIcon }, { id: 'screen-share', label: t('app:settings.nav_screen_share', { defaultValue: 'Bildschirmfreigabe' }), Icon: MonitorShareIcon }, { id: 'soundboard', label: t('app:settings.nav_soundboard', { defaultValue: 'Soundboard' }), Icon: MusicIcon }, { id: 'security', label: t('app:settings.nav_security', { defaultValue: 'Sicherheit' }), Icon: LockIcon }, { id: 'devices', label: t('app:settings.nav_devices', { defaultValue: 'Geräte' }), Icon: MonitorShareIcon }, { id: 'account', label: t('app:settings.nav_account', { defaultValue: 'Konto' }), Icon: SignOutIcon }, ]; const [activeTab, setActiveTab] = useState('profile'); return (
{/* Sidebar */} {/* Content panel — only the active tab renders */}
{/* Mobile-only header + tab selector (sidebar is hidden below lg) */}

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

{activeTab === 'profile' && (
} label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
)} {activeTab === 'appearance' && (
{SUPPORTED_LOCALES.map((locale) => { const active = (i18n.resolvedLanguage ?? i18n.language) === locale; return ( ); })}
)} {activeTab === 'privacy' && (
void patchProfile({ showReadReceipts: v })} /> void patchProfile({ allowDmsFromStrangers: v })} />
)} {activeTab === 'notifications' && (
)} {activeTab === 'voice' && (
)} {activeTab === 'screen-share' && (
)} {activeTab === 'soundboard' && (
)} {activeTab === 'security' && (
{profile?.userId && }
)} {activeTab === 'devices' && (
)} {activeTab === 'account' && (
)}
); } function AutoStartControls() { const { t } = useTranslation(['app']); const [enabled, setEnabled] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; void (async () => { const on = await isAutoStartEnabled(); if (!cancelled) setEnabled(on); })(); return () => { cancelled = true; }; }, []); async function handleToggle(next: boolean) { setBusy(true); setError(null); try { await setAutoStart(next); setEnabled(next); } catch (err: unknown) { setError(err instanceof Error ? err.message : 'autostart failed'); } finally { setBusy(false); } } return ( <> void handleToggle(v)} /> {error &&

{error}

} ); } 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 VoiceHotkeyControls({ kind }: { kind: VoiceHotkeyKind }) { const { t } = useTranslation(['app']); const [hotkeys, setHotkeys] = useState(() => getVoiceHotkeys()); const [capturing, setCapturing] = useState(false); useEffect(() => subscribeVoiceHotkeys(setHotkeys), []); useEffect(() => { if (!capturing) return; const onKey = (e: KeyboardEvent) => { // Modifier-only presses shouldn't bind — wait for a real key to // arrive. Escape aborts the capture. if (e.code === 'Escape') { e.preventDefault(); setCapturing(false); return; } if ( e.code === 'ControlLeft' || e.code === 'ControlRight' || e.code === 'ShiftLeft' || e.code === 'ShiftRight' || e.code === 'AltLeft' || e.code === 'AltRight' || e.code === 'MetaLeft' || e.code === 'MetaRight' ) { return; } e.preventDefault(); updateVoiceHotkey(kind, { key: e.code, ctrl: e.ctrlKey || e.metaKey, shift: e.shiftKey, alt: e.altKey, }); setCapturing(false); }; window.addEventListener('keydown', onKey, { capture: true }); return () => window.removeEventListener('keydown', onKey, { capture: true }); }, [capturing, kind]); const binding = hotkeys[kind]; const labels: Record = { mute: { label: t('app:settings.hotkey_mute_enabled', { defaultValue: 'Mute-Hotkey' }), hint: t('app:settings.hotkey_mute_hint', { defaultValue: 'Schaltet dein Mikro an/aus — funktioniert auch wenn ChatApp im Hintergrund ist.', }), }, deafen: { label: t('app:settings.hotkey_deafen_enabled', { defaultValue: 'Deafen-Hotkey' }), hint: t('app:settings.hotkey_deafen_hint', { defaultValue: 'Schaltet eingehendes Audio + dein Mikro stumm (wie in Discord).', }), }, hangup: { label: t('app:settings.hotkey_hangup_enabled', { defaultValue: 'Auflegen-Hotkey' }), hint: t('app:settings.hotkey_hangup_hint', { defaultValue: 'Beendet den aktiven Anruf sofort.', }), }, screenShare: { label: t('app:settings.hotkey_screenshare_enabled', { defaultValue: 'Bildschirmfreigabe-Hotkey', }), hint: t('app:settings.hotkey_screenshare_hint', { defaultValue: 'Startet oder stoppt die Bildschirmfreigabe.', }), }, video: { label: t('app:settings.hotkey_video_enabled', { defaultValue: 'Kamera-Hotkey' }), hint: t('app:settings.hotkey_video_hint', { defaultValue: 'Schaltet die Kamera während eines Anrufs an oder aus.', }), }, }; const toggleLabel = labels[kind].label; const toggleHint = labels[kind].hint; return ( <> updateVoiceHotkey(kind, { 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.', })}

updateAudioSettings({ noiseSuppression: v })} />

{t('app:settings.noise_suppression_hint', { defaultValue: 'Unterdrückt Hintergrundgeräusche (Tastatur, Lüfter, Café-Lärm). Ausschalten nur bei Musik/Instrumenten.', })}

updateAudioSettings({ videoBackgroundBlur: v })} />

{t('app:settings.video_blur_hint', { defaultValue: 'Blendet den Hintergrund hinter dir aus. Braucht etwas GPU-Leistung und lädt beim ersten Aktivieren ~1,5 MB Modell nach.', })}

updateAudioSettings({ voiceThreshold: Number(e.target.value) }) } className="flex-1 accent-accent" /> {(cfg.voiceThreshold * 100).toFixed(1)}

{t('app:settings.voice_threshold_hint', { defaultValue: 'Wann der grüne Sprech-Ring aufleuchtet. Niedriger = empfindlicher (leise Stimme erfassen), höher = tolerant gegen Raumlärm.', })}

); } function InlineToggle({ checked, onChange, }: { checked: boolean; onChange: (next: boolean) => void; }) { return ( ); } interface AvatarControlsProps { patchProfile: (patch: Parameters[1]) => Promise; busy: boolean; } function DisplayNameControls({ patchProfile, busy }: AvatarControlsProps) { const { t } = useTranslation(['app', 'auth']); const { profile } = useAuth(); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const inputRef = useRef(null); function startEdit() { setDraft(profile?.displayName ?? ''); setError(null); setEditing(true); // Focus on next tick so the input has mounted. window.setTimeout(() => inputRef.current?.focus(), 0); } function cancel() { setEditing(false); setDraft(''); setError(null); } async function save() { const trimmed = draft.trim(); if (trimmed.length === 0) { setError( t('app:settings.display_name_required', { defaultValue: 'Anzeigename darf nicht leer sein.', }), ); return; } if (trimmed === profile?.displayName) { cancel(); return; } setSaving(true); setError(null); try { await patchProfile({ displayName: trimmed }); setEditing(false); } catch (err: unknown) { setError(err instanceof Error ? err.message : 'save failed'); } finally { setSaving(false); } } if (!editing) { return (
{t('auth:signed_in.display_name')}
{profile?.displayName ?? '—'}
); } return (
{t('auth:signed_in.display_name')}
setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); void save(); } else if (e.key === 'Escape') { e.preventDefault(); cancel(); } }} maxLength={64} disabled={saving} className="flex-1 min-w-[12rem] rounded-lg border border-line bg-surface-3 px-3 py-1.5 text-sm text-fg outline-none focus:border-accent focus:ring-2 focus:ring-accent/30 disabled:opacity-60 dark:bg-[#313338]" />
{error && (

{error}

)}
); } // Default banner gradient when the user hasn't uploaded their own. Sits on // the same accent + surface tokens as the rest of the app so it never clashes // with theme changes. Used both here in settings and in UserProfilePopover. export const DEFAULT_BANNER_CLASS = 'bg-gradient-to-br from-accent/40 via-accent/15 to-surface-3'; function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) { const { t } = useTranslation(['app']); const { profile } = useAuth(); const avatarInputRef = useRef(null); const bannerInputRef = useRef(null); const [avatarBusy, setAvatarBusy] = useState(false); const [bannerBusy, setBannerBusy] = useState(false); const [avatarError, setAvatarError] = useState(null); const [bannerError, setBannerError] = useState(null); // Crop-dialog plumbing. The picked File lives here until the user // confirms a crop or cancels; on confirm we hand the resulting Blob to // the matching upload helper. Keeping `kind` separate from `file` lets // the same dialog component drive both flows with different aspect // ratios. const [cropFile, setCropFile] = useState(null); const [cropKind, setCropKind] = useState<'avatar' | 'banner' | null>(null); // Lightbox toggle for the avatar live-preview. Clicking the in-page // avatar opens a fullscreen view; clicking outside / Esc dismisses. const [avatarPreviewOpen, setAvatarPreviewOpen] = useState(false); const userId = profile?.userId; const avatarUrl = profile?.avatarUrl ?? null; const bannerUrl = profile?.bannerUrl ?? null; // Avatar pick → open crop dialog. Legacy `uploadAvatar` (center-crop) is // kept around for callers that bypass the picker, but the SettingsPage // path always goes through the crop flow now so the user controls the // framing. function openAvatarCrop(file: File) { if (!userId) return; if (!file.type.startsWith('image/')) { setAvatarError('only image files are accepted'); return; } setAvatarError(null); setCropFile(file); setCropKind('avatar'); } async function handleAvatarCropConfirm(blob: Blob) { if (!userId) return; setAvatarBusy(true); setAvatarError(null); try { const newUrl = await uploadAvatarBlob(userId, blob); const oldUrl = avatarUrl; await patchProfile({ avatarUrl: newUrl }); if (oldUrl) { void deleteAvatarObject(oldUrl).catch(() => undefined); } closeCropDialog(); } catch (err: unknown) { setAvatarError(err instanceof Error ? err.message : 'upload failed'); } finally { setAvatarBusy(false); } } function closeCropDialog() { setCropFile(null); setCropKind(null); if (avatarInputRef.current) avatarInputRef.current.value = ''; if (bannerInputRef.current) bannerInputRef.current.value = ''; } async function handleAvatarRemove() { if (!userId || !avatarUrl) return; setAvatarError(null); setAvatarBusy(true); try { await patchProfile({ avatarUrl: null }); void deleteAvatarObject(avatarUrl).catch(() => undefined); } catch (err: unknown) { setAvatarError(err instanceof Error ? err.message : 'remove failed'); } finally { setAvatarBusy(false); } } function openBannerCrop(file: File) { if (!userId) return; if (!file.type.startsWith('image/')) { setBannerError('only image files are accepted'); return; } if (file.size > BANNER_MAX_INPUT_BYTES) { setBannerError('image must be 8 MB or smaller'); return; } setBannerError(null); setCropFile(file); setCropKind('banner'); } async function handleBannerCropConfirm(blob: Blob) { if (!userId) return; setBannerBusy(true); setBannerError(null); try { const newUrl = await uploadBannerBlob(userId, blob); const oldUrl = bannerUrl; await patchProfile({ bannerUrl: newUrl }); if (oldUrl) { void deleteBannerObject(oldUrl).catch(() => undefined); } closeCropDialog(); } catch (err: unknown) { setBannerError(err instanceof Error ? err.message : 'upload failed'); } finally { setBannerBusy(false); } } async function handleBannerRemove() { if (!userId || !bannerUrl) return; setBannerError(null); setBannerBusy(true); try { await patchProfile({ bannerUrl: null }); void deleteBannerObject(bannerUrl).catch(() => undefined); } catch (err: unknown) { setBannerError(err instanceof Error ? err.message : 'remove failed'); } finally { setBannerBusy(false); } } const displayName = profile?.displayName ?? profile?.username; const lockedAll = busy || avatarBusy || bannerBusy; return (
{/* Live preview — banner with avatar overlapping bottom-left, mirrors how the profile shows up in UserProfilePopover. The avatar row is explicitly stacked above the banner via `relative z-10`; without it, browsers can paint the negatively-margin'd avatar behind the banner's background image when the parent doesn't establish a stacking context. */}
{displayName ?? '—'}
{profile?.username && (
@{profile.username}
)}
{/* Banner controls */}
{t('app:settings.banner', { defaultValue: 'Banner' })}
{t('app:settings.banner_hint', { defaultValue: '3:1 Format, max 8 MB. Standard ist ein Farbverlauf.', })}
{bannerError && (
{bannerError}
)}
{ const f = e.target.files?.[0]; if (f) openBannerCrop(f); }} /> {bannerUrl && ( )}
{/* Avatar controls */}
{t('app:settings.avatar', { defaultValue: 'Avatar' })}
{t('app:settings.avatar_hint', { defaultValue: 'Quadratisch, max 512×512, WebP. Sichtbar für deine Freunde.', })}
{avatarError && (
{avatarError}
)}
{ const f = e.target.files?.[0]; if (f) openAvatarCrop(f); }} /> {avatarUrl && ( )}
{ if (cropKind === 'banner') void handleBannerCropConfirm(blob); else if (cropKind === 'avatar') void handleAvatarCropConfirm(blob); }} onClose={closeCropDialog} /> {avatarPreviewOpen && avatarUrl && ( setAvatarPreviewOpen(false)} /> )}
); } 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, description, children, tone, }: { title: string; description?: string; children: React.ReactNode; tone?: 'default' | 'danger'; }) { return (

{title}

{description && (

{description}

)}
{children}
); } // Sub-heading inside a Section — used to chunk dense sections like Voice into // smaller named groups (Audio-Gerät / Qualität / PTT / Hotkeys / E2EE). // No border: `--color-line` is already a semi-transparent token, and applying // the `/60` opacity modifier brightens it (Tailwind overrides the original // alpha) which made the sub-cards look harsher than the outer Section. Plain // background tint + caps heading carry the grouping signal on their own. function SubSection({ title, children }: { title: string; children: React.ReactNode }) { return (

{title}

{children}
); } // Lighter wrapper for a single related extra control inside a Section that // doesn't warrant its own SubSection card (e.g., autostart toggle inside // Appearance). function SubGroup({ children }: { children: React.ReactNode }) { return (
{children}
); } function Row({ label, value, mono, icon, }: { label: string; value: string; mono?: boolean; icon?: React.ReactNode; }) { return (
{icon} {label}
{value}
); } function SettingRow({ label, children }: { label: string; children: React.ReactNode }) { return (
{label} {children}
); } // Theme picker row inside the Appearance section. Same pill-segmented style // as the language selector so the two siblings read as one control surface. // The toggle was previously a rail icon in the sidebar; moved here so it // sits with the other appearance preferences. function ThemeRow() { const { t } = useTranslation(['app']); const { theme, setTheme } = useTheme(); const options: Array<{ value: 'light' | 'dark'; label: string }> = [ { value: 'light', label: t('app:theme.light', { defaultValue: 'Light' }), }, { value: 'dark', label: t('app:theme.dark', { defaultValue: 'Dark' }), }, ]; return (
{options.map((o) => { const active = theme === o.value; return ( ); })}
); } 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 //