From 5c05afb0098cc483956d77994b2f8644240ea505 Mon Sep 17 00:00:00 2001 From: byGalax Date: Sat, 16 May 2026 01:08:28 +0200 Subject: [PATCH] feat(desktop): redesign Settings + fix unreadable bubble text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two UI fixes: 1. MessageBubble: 'Nachricht nicht lesbar' was rendered with text-fg-muted on the blue 'mine' bubble — invisible. Now uses text-accent-fg/80 on mine, text-fg-muted on peer (still ≥4.5:1 contrast in both modes). 2. SettingsPage: redesigned from a long single-column scroll into a sticky-sidebar + content layout (lg+) with: - 9 anchor-linked sections with icons in the sidebar - IntersectionObserver highlights the active section - Each section has a description subtitle for context - Voice (the densest section) is now sub-grouped into Audio-Gerät / Qualität / PTT / Hotkeys / E2EE via SubSection cards - Notifications consolidates message-sound + ringtone - Danger-toned account section visually separated - Mobile fallback is the original single-column scroll --- apps/desktop/src/components/MessageBubble.tsx | 9 +- apps/desktop/src/pages/SettingsPage.tsx | 445 ++++++++++++------ 2 files changed, 322 insertions(+), 132 deletions(-) diff --git a/apps/desktop/src/components/MessageBubble.tsx b/apps/desktop/src/components/MessageBubble.tsx index 16d0a2f..9945171 100644 --- a/apps/desktop/src/components/MessageBubble.tsx +++ b/apps/desktop/src/components/MessageBubble.tsx @@ -416,7 +416,14 @@ export function MessageBubble({ )} {message.plaintext === null ? ( - + {t('app:chats.unreadable', { defaultValue: 'Nachricht nicht lesbar', })} diff --git a/apps/desktop/src/pages/SettingsPage.tsx b/apps/desktop/src/pages/SettingsPage.tsx index 5e7819a..966a766 100644 --- a/apps/desktop/src/pages/SettingsPage.tsx +++ b/apps/desktop/src/pages/SettingsPage.tsx @@ -8,6 +8,18 @@ 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'; @@ -99,140 +111,251 @@ export function SettingsPage() { void patchProfile({ locale }); } + // Single-page layout with a sticky sidebar of anchor links on wide screens. + // Each Section gets a stable `id` so links scroll to it; an IntersectionObserver + // highlights whichever section is currently in view (active link). + const sections = [ + { 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: 'account', label: t('app:settings.nav_account', { defaultValue: 'Konto' }), Icon: SignOutIcon }, + ]; + + const [activeId, setActiveId] = useState(sections[0]!.id); + useEffect(() => { + if (typeof IntersectionObserver === 'undefined') return; + const observer = new IntersectionObserver( + (entries) => { + const visible = entries + .filter((e) => e.isIntersecting) + .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0]; + if (visible?.target.id) setActiveId(visible.target.id); + }, + { rootMargin: '-20% 0px -70% 0px', threshold: [0, 0.5, 1] }, + ); + sections.forEach((s) => { + const el = document.getElementById(s.id); + if (el) observer.observe(el); + }); + return () => observer.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return (
-
-
-

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

-
- - {/* Account */} -
- - - - -
- - {/* Startup */} -
- -
- - {/* Appearance */} -
- - -
- {SUPPORTED_LOCALES.map((locale) => { - const active = (i18n.resolvedLanguage ?? i18n.language) === locale; - return ( - - ); - })} +
+ {/* Sidebar — sticky anchor nav, hidden on narrow screens */} +
+ - {/* Privacy */} -
- void patchProfile({ showReadReceipts: v })} - /> - void patchProfile({ allowDmsFromStrangers: v })} - /> -
+ {/* Content column */} +
+ {/* Mobile-only header (sidebar replaces it on lg+) */} +
+

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

+
- {/* Notification sound (new messages) */} -
- -
+
+ + + + } label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono /> +
- {/* Ringtone (incoming custom) */} -
- -
+
+ + +
+ {SUPPORTED_LOCALES.map((locale) => { + const active = (i18n.resolvedLanguage ?? i18n.language) === locale; + return ( + + ); + })} +
+
+ + + +
- {/* Soundboard */} -
- -
+
+ void patchProfile({ showReadReceipts: v })} + /> + void patchProfile({ allowDmsFromStrangers: v })} + /> +
- {/* Voice / Push-to-Talk + Audio Quality + E2EE */} -
- -
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
+
+ + + + + + +
- {/* Screen-share quality */} -
- -
+
+ + + + + + + + + + +
+ + + + + +
+
+ + + +
- {/* Security */} -
- {profile?.userId && } -
+
+ +
- {/* Danger zone */} -
- -
+
+ +
+ +
+ {profile?.userId && } +
+ +
+ +
+
); @@ -1138,21 +1261,81 @@ function formatBitrate(kbps: number): string { return kbps + ' kbps'; } -function Section({ title, children }: { title: string; children: React.ReactNode }) { +function Section({ + id, + title, + description, + children, + tone, +}: { + id: string; + title: string; + description?: string; + children: React.ReactNode; + tone?: 'default' | 'danger'; +}) { return ( -
-

- {title} -

-
{children}
+
+
+

+ {title} +

+ {description && ( +

{description}

+ )} +
+
{children}
); } -function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) { +// Sub-heading inside a Section — used to chunk dense sections like Voice into +// smaller named groups (Audio-Gerät / Qualität / PTT / Hotkeys / E2EE). +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 (
-
{label}
+
+ {icon} + {label} +