feat(desktop): port v0.11.4-v0.15.2 from Tauri to Electron + Discord-parity audio (v0.16.0)
Chronological port of every Tauri release commit (v0.11.4 -> v0.15.2)
into the Electron rebuild, plus Discord-style audio handling that goes
beyond the Tauri original.
Highlights:
- All 17 Tauri release commits ported (audio fixes, custom notification
sound, Discord-style chat UX, profile banner, changelog page,
Discord-parity call UX, screen-share echo + re-watch UX, audio-loop
fix).
- Native napi-rs audio-loopback addon with WASAPI process-loopback:
* EXCLUDE_TARGET_PROCESS_TREE for full-screen shares -> peers
never hear themselves echoed back through the capture.
* INCLUDE_TARGET_PROCESS_TREE for window shares -> only the
picked window's audio is captured, not the whole OS mixer
(Discord parity).
* HWND -> PID resolution via Win32 GetWindowThreadProcessId.
- Discord-style screen-source picker (thumbnail grid, screens vs
apps tabs, live-refreshing thumbnails).
- Hash routing fix for packaged builds (file:// can't resolve
BrowserRouter paths).
- Tauri sources removed (apps/desktop/src-tauri).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,13 +10,29 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Avatar } from '../components/Avatar';
|
||||
import { BackupExportDialog } from '../components/BackupExportDialog';
|
||||
import { BackupRestoreDialog } from '../components/BackupRestoreDialog';
|
||||
import { MicTestSection } from '../components/MicTestSection';
|
||||
import { NotificationSoundSettings } from '../components/NotificationSoundSettings';
|
||||
import { RingtoneSettings } from '../components/RingtoneSettings';
|
||||
import { SoundboardSettings } from '../components/SoundboardSettings';
|
||||
import { LockIcon } from '../components/icons';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import { deleteAvatarObject, uploadAvatar } from '../lib/avatarUpload';
|
||||
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 { devLocalSecretStore } from '../lib/secretStore';
|
||||
import {
|
||||
getPttSettings,
|
||||
@@ -97,17 +113,20 @@ export function SettingsPage() {
|
||||
|
||||
{/* Account */}
|
||||
<Section title={t('app:settings.section_account')}>
|
||||
<AvatarControls
|
||||
patchProfile={patchProfile}
|
||||
busy={busy}
|
||||
/>
|
||||
<ProfileVisualsControls patchProfile={patchProfile} busy={busy} />
|
||||
<Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} />
|
||||
<Row label={t('auth:signed_in.display_name')} value={profile?.displayName ?? '—'} />
|
||||
<DisplayNameControls patchProfile={patchProfile} busy={busy} />
|
||||
<Row label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
|
||||
</Section>
|
||||
|
||||
{/* Startup */}
|
||||
<Section title={t('app:settings.section_startup', { defaultValue: 'Start' })}>
|
||||
<AutoStartControls />
|
||||
</Section>
|
||||
|
||||
{/* Appearance */}
|
||||
<Section title={t('app:settings.section_appearance')}>
|
||||
<ThemeRow />
|
||||
<SettingRow label={t('app:settings.language')}>
|
||||
<div className="inline-flex rounded-lg border border-line bg-surface-3 p-1">
|
||||
{SUPPORTED_LOCALES.map((locale) => {
|
||||
@@ -151,6 +170,13 @@ export function SettingsPage() {
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Notification sound (new messages) */}
|
||||
<Section
|
||||
title={t('app:settings.section_notifications', { defaultValue: 'Benachrichtigungen' })}
|
||||
>
|
||||
<NotificationSoundSettings disabled={busy} />
|
||||
</Section>
|
||||
|
||||
{/* Ringtone (incoming custom) */}
|
||||
<Section title={t('app:settings.section_ringtone', { defaultValue: 'Klingelton' })}>
|
||||
<RingtoneSettings disabled={busy} />
|
||||
@@ -176,6 +202,15 @@ export function SettingsPage() {
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<VoiceHotkeyControls kind="deafen" />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<VoiceHotkeyControls kind="hangup" />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<VoiceHotkeyControls kind="screenShare" />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<VoiceHotkeyControls kind="video" />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<CallE2EEControls />
|
||||
</div>
|
||||
@@ -219,6 +254,55 @@ export function SettingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function AutoStartControls() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const [enabled, setEnabled] = useState<boolean | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<>
|
||||
<Toggle
|
||||
label={t('app:settings.autostart', {
|
||||
defaultValue: 'Mit Windows starten',
|
||||
})}
|
||||
hint={t('app:settings.autostart_hint', {
|
||||
defaultValue:
|
||||
'ChatApp automatisch mitstarten wenn du dich am System anmeldest.',
|
||||
})}
|
||||
checked={enabled ?? false}
|
||||
disabled={busy || enabled === null}
|
||||
onChange={(v) => void handleToggle(v)}
|
||||
/>
|
||||
{error && <p className="text-xs text-rose-600 dark:text-rose-300">{error}</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PttControls() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const [ptt, setPtt] = useState<PttSettings>(() => getPttSettings());
|
||||
@@ -319,20 +403,43 @@ function VoiceHotkeyControls({ kind }: { kind: VoiceHotkeyKind }) {
|
||||
}, [capturing, kind]);
|
||||
|
||||
const binding = hotkeys[kind];
|
||||
const toggleLabel =
|
||||
kind === 'mute'
|
||||
? t('app:settings.hotkey_mute_enabled', { defaultValue: 'Mute-Hotkey' })
|
||||
: t('app:settings.hotkey_deafen_enabled', { defaultValue: 'Deafen-Hotkey' });
|
||||
const toggleHint =
|
||||
kind === 'mute'
|
||||
? t('app:settings.hotkey_mute_hint', {
|
||||
defaultValue:
|
||||
'Schaltet dein Mikro an/aus — funktioniert auch wenn ChatApp im Hintergrund ist.',
|
||||
})
|
||||
: t('app:settings.hotkey_deafen_hint', {
|
||||
defaultValue:
|
||||
'Schaltet eingehendes Audio + dein Mikro stumm (wie in Discord).',
|
||||
});
|
||||
const labels: Record<VoiceHotkeyKind, { label: string; hint: string }> = {
|
||||
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 (
|
||||
<>
|
||||
@@ -549,106 +656,411 @@ interface AvatarControlsProps {
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
function AvatarControls({ patchProfile, busy }: AvatarControlsProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
function DisplayNameControls({ patchProfile, busy }: AvatarControlsProps) {
|
||||
const { t } = useTranslation(['app', 'auth']);
|
||||
const { profile } = useAuth();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const userId = profile?.userId;
|
||||
const url = profile?.avatarUrl ?? null;
|
||||
|
||||
async function handleFile(file: File) {
|
||||
if (!userId) return;
|
||||
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);
|
||||
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 */
|
||||
});
|
||||
}
|
||||
await patchProfile({ displayName: trimmed });
|
||||
setEditing(false);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'upload failed');
|
||||
setError(err instanceof Error ? err.message : 'save failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
if (!editing) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<dt className="text-sm text-fg-muted">{t('auth:signed_in.display_name')}</dt>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<dd className="max-w-[40ch] truncate text-right text-sm text-fg" title={profile?.displayName ?? ''}>
|
||||
{profile?.displayName ?? '—'}
|
||||
</dd>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startEdit}
|
||||
disabled={busy || !profile}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-2.5 py-1 text-xs font-semibold text-fg transition hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-[#313338] dark:hover:bg-[#2b2d31]"
|
||||
>
|
||||
{t('app:settings.edit', { defaultValue: 'Bearbeiten' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar
|
||||
url={url}
|
||||
displayName={profile?.displayName ?? profile?.username}
|
||||
className="h-16 w-16 text-2xl"
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-fg-muted">{t('auth:signed_in.display_name')}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={draft}
|
||||
onChange={(e) => 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]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void save()}
|
||||
disabled={saving || busy}
|
||||
className="cursor-pointer rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saving
|
||||
? t('app:settings.display_name_saving', { defaultValue: 'Speichere…' })
|
||||
: t('app:settings.save', { defaultValue: 'Speichern' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancel}
|
||||
disabled={saving}
|
||||
className="cursor-pointer rounded-lg border border-line bg-surface-3 px-3 py-1.5 text-xs font-semibold text-fg transition hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-[#313338] dark:hover:bg-[#2b2d31]"
|
||||
>
|
||||
{t('app:settings.cancel', { defaultValue: 'Abbrechen' })}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs text-rose-500 dark:text-rose-300">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="text-sm font-medium text-fg">
|
||||
{t('app:settings.avatar', { defaultValue: 'Avatar' })}
|
||||
// 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<HTMLInputElement | null>(null);
|
||||
const bannerInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [avatarBusy, setAvatarBusy] = useState(false);
|
||||
const [bannerBusy, setBannerBusy] = useState(false);
|
||||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||
const [bannerError, setBannerError] = useState<string | null>(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<File | null>(null);
|
||||
const [cropKind, setCropKind] = useState<'avatar' | 'banner' | null>(null);
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{/* 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. */}
|
||||
<div className="relative overflow-hidden rounded-xl border border-line bg-surface-3">
|
||||
<div
|
||||
className={
|
||||
'relative z-0 aspect-[3/1] w-full bg-cover bg-center ' +
|
||||
(bannerUrl ? '' : DEFAULT_BANNER_CLASS)
|
||||
}
|
||||
style={bannerUrl ? { backgroundImage: 'url("' + bannerUrl + '")' } : undefined}
|
||||
/>
|
||||
<div
|
||||
className="relative z-10 flex items-end gap-3 px-4 pb-3"
|
||||
style={{ marginTop: '-2rem' }}
|
||||
>
|
||||
<Avatar
|
||||
url={avatarUrl}
|
||||
displayName={displayName}
|
||||
className="h-16 w-16 rounded-full text-2xl ring-4 ring-surface-3"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 pb-1">
|
||||
<div className="truncate text-sm font-semibold text-fg">
|
||||
{displayName ?? '—'}
|
||||
</div>
|
||||
{profile?.username && (
|
||||
<div className="truncate text-xs text-fg-muted">@{profile.username}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-fg-muted">
|
||||
{t('app:settings.avatar_hint', {
|
||||
defaultValue: 'Quadratisch, max 512×512, WebP. Sichtbar für deine Freunde.',
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Banner controls */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-fg">
|
||||
{t('app:settings.banner', { defaultValue: 'Banner' })}
|
||||
</div>
|
||||
<div className="text-xs text-fg-muted">
|
||||
{t('app:settings.banner_hint', {
|
||||
defaultValue: '3:1 Format, max 8 MB. Standard ist ein Farbverlauf.',
|
||||
})}
|
||||
</div>
|
||||
{bannerError && (
|
||||
<div className="mt-1 text-xs text-rose-500 dark:text-rose-300">{bannerError}</div>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-xs text-rose-500 dark:text-rose-300">{error}</div>
|
||||
<input
|
||||
ref={bannerInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) openBannerCrop(f);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => bannerInputRef.current?.click()}
|
||||
disabled={lockedAll}
|
||||
className="cursor-pointer rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{bannerBusy
|
||||
? t('app:settings.avatar_uploading', { defaultValue: 'Lade…' })
|
||||
: bannerUrl
|
||||
? t('app:settings.avatar_change', { defaultValue: 'Ändern' })
|
||||
: t('app:settings.avatar_upload', { defaultValue: 'Hochladen' })}
|
||||
</button>
|
||||
{bannerUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleBannerRemove()}
|
||||
disabled={lockedAll}
|
||||
className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
|
||||
>
|
||||
{t('app:settings.avatar_remove', { defaultValue: 'Entfernen' })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void handleFile(f);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={busy || uploading}
|
||||
className="cursor-pointer rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{uploading
|
||||
? t('app:settings.avatar_uploading', { defaultValue: 'Lade…' })
|
||||
: url
|
||||
? t('app:settings.avatar_change', { defaultValue: 'Ändern' })
|
||||
: t('app:settings.avatar_upload', { defaultValue: 'Hochladen' })}
|
||||
</button>
|
||||
{url && (
|
||||
{/* Avatar controls */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-line pt-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-fg">
|
||||
{t('app:settings.avatar', { defaultValue: 'Avatar' })}
|
||||
</div>
|
||||
<div className="text-xs text-fg-muted">
|
||||
{t('app:settings.avatar_hint', {
|
||||
defaultValue: 'Quadratisch, max 512×512, WebP. Sichtbar für deine Freunde.',
|
||||
})}
|
||||
</div>
|
||||
{avatarError && (
|
||||
<div className="mt-1 text-xs text-rose-500 dark:text-rose-300">{avatarError}</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) openAvatarCrop(f);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRemove()}
|
||||
disabled={busy || uploading}
|
||||
className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
disabled={lockedAll}
|
||||
className="cursor-pointer rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:settings.avatar_remove', { defaultValue: 'Entfernen' })}
|
||||
{avatarBusy
|
||||
? t('app:settings.avatar_uploading', { defaultValue: 'Lade…' })
|
||||
: avatarUrl
|
||||
? t('app:settings.avatar_change', { defaultValue: 'Ändern' })
|
||||
: t('app:settings.avatar_upload', { defaultValue: 'Hochladen' })}
|
||||
</button>
|
||||
)}
|
||||
{avatarUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAvatarRemove()}
|
||||
disabled={lockedAll}
|
||||
className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
|
||||
>
|
||||
{t('app:settings.avatar_remove', { defaultValue: 'Entfernen' })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ImageCropDialog
|
||||
open={cropFile !== null && cropKind !== null}
|
||||
file={cropFile}
|
||||
aspect={cropKind === 'banner' ? 3 : 1}
|
||||
outputWidth={cropKind === 'banner' ? BANNER_TARGET_WIDTH : AVATAR_TARGET_DIM}
|
||||
outputHeight={cropKind === 'banner' ? BANNER_TARGET_HEIGHT : AVATAR_TARGET_DIM}
|
||||
title={
|
||||
cropKind === 'banner'
|
||||
? t('app:settings.crop_banner_title', { defaultValue: 'Banner zuschneiden' })
|
||||
: t('app:settings.crop_avatar_title', { defaultValue: 'Profilbild zuschneiden' })
|
||||
}
|
||||
onConfirm={(blob) => {
|
||||
if (cropKind === 'banner') void handleBannerCropConfirm(blob);
|
||||
else if (cropKind === 'avatar') void handleAvatarCropConfirm(blob);
|
||||
}}
|
||||
onClose={closeCropDialog}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -837,6 +1249,49 @@ function SettingRow({ label, children }: { label: string; children: React.ReactN
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<SettingRow label={t('app:settings.theme', { defaultValue: 'Design' })}>
|
||||
<div className="inline-flex rounded-lg border border-line bg-surface-3 p-1">
|
||||
{options.map((o) => {
|
||||
const active = theme === o.value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => setTheme(o.value)}
|
||||
className={
|
||||
'cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(active
|
||||
? 'bg-accent text-accent-fg'
|
||||
: 'text-fg-muted hover:text-fg')
|
||||
}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
label,
|
||||
hint,
|
||||
@@ -880,15 +1335,19 @@ function Toggle({
|
||||
|
||||
function AudioDeviceControls() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { setAudioInputDevice, setAudioOutputDevice } = useCall();
|
||||
const { setAudioInputDevice, setAudioOutputDevice, setVideoInputDevice } = useCall();
|
||||
const [inputs, setInputs] = useState<MediaDeviceInfo[]>([]);
|
||||
const [outputs, setOutputs] = useState<MediaDeviceInfo[]>([]);
|
||||
const [cameras, setCameras] = useState<MediaDeviceInfo[]>([]);
|
||||
const [inputId, setInputId] = useState<string | null>(
|
||||
() => getAudioSettings().inputDeviceId,
|
||||
);
|
||||
const [outputId, setOutputId] = useState<string | null>(
|
||||
() => getAudioSettings().outputDeviceId,
|
||||
);
|
||||
const [cameraId, setCameraId] = useState<string | null>(
|
||||
() => getAudioSettings().videoInputDeviceId,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [permission, setPermission] = useState<'unknown' | 'granted' | 'denied'>(
|
||||
'unknown',
|
||||
@@ -899,6 +1358,7 @@ function AudioDeviceControls() {
|
||||
const list = await navigator.mediaDevices.enumerateDevices();
|
||||
setInputs(list.filter((d) => d.kind === 'audioinput'));
|
||||
setOutputs(list.filter((d) => d.kind === 'audiooutput'));
|
||||
setCameras(list.filter((d) => d.kind === 'videoinput'));
|
||||
// If labels are empty, permission hasn't been granted yet — browsers
|
||||
// mask device names until a getUserMedia call succeeds at least once.
|
||||
const hasLabels = list.some(
|
||||
@@ -921,6 +1381,7 @@ function AudioDeviceControls() {
|
||||
const unsubSettings = subscribeAudioSettings((s) => {
|
||||
setInputId(s.inputDeviceId);
|
||||
setOutputId(s.outputDeviceId);
|
||||
setCameraId(s.videoInputDeviceId);
|
||||
});
|
||||
return () => {
|
||||
try {
|
||||
@@ -963,6 +1424,15 @@ function AudioDeviceControls() {
|
||||
[setAudioOutputDevice],
|
||||
);
|
||||
|
||||
const handleCamera = useCallback(
|
||||
async (id: string) => {
|
||||
const next = id === '' ? null : id;
|
||||
setCameraId(next);
|
||||
await setVideoInputDevice(next);
|
||||
},
|
||||
[setVideoInputDevice],
|
||||
);
|
||||
|
||||
const outputSupported =
|
||||
typeof HTMLAudioElement !== 'undefined' &&
|
||||
typeof HTMLAudioElement.prototype.setSinkId === 'function';
|
||||
@@ -1039,6 +1509,38 @@ function AudioDeviceControls() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-fg">
|
||||
{t('app:settings.camera_title', { defaultValue: 'Kamera' })}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
{t('app:settings.camera_hint', {
|
||||
defaultValue:
|
||||
'Bevorzugte Kamera. Bei aktivem Anruf wird live umgeschaltet.',
|
||||
})}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<select
|
||||
value={cameraId ?? ''}
|
||||
onChange={(e) => void handleCamera(e.target.value)}
|
||||
className="flex-1 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
>
|
||||
<option value="">
|
||||
{t('app:settings.mic_default', { defaultValue: 'Systemstandard' })}
|
||||
</option>
|
||||
{cameras.map((d) => (
|
||||
<option key={d.deviceId} value={d.deviceId}>
|
||||
{d.label || t('app:settings.mic_unnamed', { defaultValue: 'Unbenannt' })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-line pt-3">
|
||||
<MicTestSection />
|
||||
</div>
|
||||
|
||||
{permission !== 'granted' && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-200">
|
||||
<span>
|
||||
|
||||
Reference in New Issue
Block a user