Compare commits

...

4 Commits

Author SHA1 Message Date
byGalax 0288d7a476 chore(desktop): release v0.18.7 2026-05-16 15:30:14 +02:00
byGalax 34b972ec2a refactor(desktop): Settings = tab pattern instead of scroll + observer
The previous build used IntersectionObserver to highlight whichever
section was in view. With nine sections of unequal heights and smooth-
scroll firing observer callbacks mid-scroll, the active highlight
drifted (clicking 'Konto' showed 'Soundboard' as active because the
last section never crossed the observer's 20%-30% band).

Switched to a tab pattern (macOS System Settings / Discord / GitHub
style): the sidebar selects ONE section, only that section renders.
`useState<TabId>` is the single source of truth; no observer, no
scrolling between sections, no anchor links to clash with HashRouter.
Mobile fallback (<lg) gets a `<select>` dropdown above the panel.

Drops the now-unused `id` prop from Section and removes the
IntersectionObserver effect.
2026-05-16 15:28:03 +02:00
byGalax c55173800b chore(desktop): release v0.18.6 2026-05-16 01:13:54 +02:00
byGalax fc9f1ec143 fix(desktop): Settings sidebar redirected to /chats instead of scrolling
App uses HashRouter, so an <a href="#profile"> changes the routing
hash and the router can't find a match — it falls back to /chats.
Replace anchors with buttons that scroll the target section via
scrollIntoView and update the active highlight optimistically.
2026-05-16 01:12:50 +02:00
2 changed files with 226 additions and 215 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@chat-app/desktop", "name": "@chat-app/desktop",
"version": "0.18.5", "version": "0.18.7",
"private": true, "private": true,
"description": "Electron desktop client (Windows / macOS / Linux)", "description": "Electron desktop client (Windows / macOS / Linux)",
"type": "module", "type": "module",
+62 -51
View File
@@ -111,10 +111,15 @@ export function SettingsPage() {
void patchProfile({ locale }); void patchProfile({ locale });
} }
// Single-page layout with a sticky sidebar of anchor links on wide screens. // Tab pattern (macOS / Discord / GitHub style): the sidebar selects ONE
// Each Section gets a stable `id` so links scroll to it; an IntersectionObserver // section and only that section renders. activeTab is the single source of
// highlights whichever section is currently in view (active link). // truth — no IntersectionObserver to drift, no smooth-scroll, no anchor-link
const sections = [ // routing conflict with HashRouter.
type TabId =
| 'profile' | 'appearance' | 'privacy' | 'notifications'
| 'voice' | 'screen-share' | 'soundboard' | 'security' | 'account';
const tabs: Array<{ id: TabId; label: string; Icon: typeof UsersIcon }> = [
{ id: 'profile', label: t('app:settings.nav_profile', { defaultValue: 'Profil' }), Icon: 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: '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: 'privacy', label: t('app:settings.nav_privacy', { defaultValue: 'Privatsphäre' }), Icon: ShieldIcon },
@@ -126,44 +131,30 @@ export function SettingsPage() {
{ id: 'account', label: t('app:settings.nav_account', { defaultValue: 'Konto' }), Icon: SignOutIcon }, { id: 'account', label: t('app:settings.nav_account', { defaultValue: 'Konto' }), Icon: SignOutIcon },
]; ];
const [activeId, setActiveId] = useState<string>(sections[0]!.id); const [activeTab, setActiveTab] = useState<TabId>('profile');
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 ( return (
<div className="min-h-full bg-surface-3 text-fg"> <div className="min-h-full bg-surface-3 text-fg">
<div className="mx-auto grid max-w-6xl gap-8 px-6 py-8 lg:grid-cols-[14rem_minmax(0,1fr)]"> <div className="mx-auto grid max-w-6xl gap-8 px-6 py-8 lg:grid-cols-[14rem_minmax(0,1fr)]">
{/* Sidebar — sticky anchor nav, hidden on narrow screens */} {/* Sidebar */}
<aside className="hidden lg:block"> <aside className="hidden lg:block">
<div className="sticky top-8 space-y-1"> <div className="sticky top-8 space-y-1">
<h1 className="mb-4 px-3 font-display text-2xl font-semibold tracking-tight text-fg"> <h1 className="mb-4 px-3 font-display text-2xl font-semibold tracking-tight text-fg">
{t('app:settings.title')} {t('app:settings.title')}
</h1> </h1>
<nav aria-label={t('app:settings.title')}> <nav aria-label={t('app:settings.title')} role="tablist" aria-orientation="vertical">
{sections.map(({ id, label, Icon }) => { {tabs.map(({ id, label, Icon }) => {
const active = activeId === id; const active = activeTab === id;
return ( return (
<a <button
key={id} key={id}
href={'#' + id} type="button"
role="tab"
aria-selected={active}
aria-controls={'settings-panel-' + id}
onClick={() => setActiveTab(id)}
className={ className={
'flex cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' + 'flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(active (active
? 'bg-accent/15 text-fg' ? 'bg-accent/15 text-fg'
: 'text-fg-muted hover:bg-surface-2 hover:text-fg') : 'text-fg-muted hover:bg-surface-2 hover:text-fg')
@@ -175,24 +166,39 @@ export function SettingsPage() {
} }
/> />
<span className="truncate">{label}</span> <span className="truncate">{label}</span>
</a> </button>
); );
})} })}
</nav> </nav>
</div> </div>
</aside> </aside>
{/* Content column */} {/* Content panel — only the active tab renders */}
<main className="space-y-8"> <main className="min-w-0">
{/* Mobile-only header (sidebar replaces it on lg+) */} {/* Mobile-only header + tab selector (sidebar is hidden below lg) */}
<header className="lg:hidden"> <div className="mb-6 space-y-3 lg:hidden">
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg"> <h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
{t('app:settings.title')} {t('app:settings.title')}
</h1> </h1>
</header> <select
value={activeTab}
onChange={(e) => setActiveTab(e.target.value as TabId)}
className="w-full cursor-pointer rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
aria-label={t('app:settings.title')}
>
{tabs.map(({ id, label }) => (
<option key={id} value={id}>{label}</option>
))}
</select>
</div>
<div
id={'settings-panel-' + activeTab}
role="tabpanel"
aria-labelledby={'settings-tab-' + activeTab}
>
{activeTab === 'profile' && (
<Section <Section
id="profile"
title={t('app:settings.section_account')} title={t('app:settings.section_account')}
description={t('app:settings.section_account_hint', { description={t('app:settings.section_account_hint', {
defaultValue: 'Dein öffentliches Profil und wie andere dich sehen.', defaultValue: 'Dein öffentliches Profil und wie andere dich sehen.',
@@ -203,9 +209,10 @@ export function SettingsPage() {
<DisplayNameControls patchProfile={patchProfile} busy={busy} /> <DisplayNameControls patchProfile={patchProfile} busy={busy} />
<Row icon={<AtIcon className="h-3.5 w-3.5" />} label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono /> <Row icon={<AtIcon className="h-3.5 w-3.5" />} label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
</Section> </Section>
)}
{activeTab === 'appearance' && (
<Section <Section
id="appearance"
title={t('app:settings.section_appearance')} title={t('app:settings.section_appearance')}
description={t('app:settings.section_appearance_hint', { description={t('app:settings.section_appearance_hint', {
defaultValue: 'Theme, Sprache und Verhalten beim Systemstart.', defaultValue: 'Theme, Sprache und Verhalten beim Systemstart.',
@@ -239,9 +246,10 @@ export function SettingsPage() {
<AutoStartControls /> <AutoStartControls />
</SubGroup> </SubGroup>
</Section> </Section>
)}
{activeTab === 'privacy' && (
<Section <Section
id="privacy"
title={t('app:settings.section_privacy')} title={t('app:settings.section_privacy')}
description={t('app:settings.section_privacy_hint', { description={t('app:settings.section_privacy_hint', {
defaultValue: 'Wer dich kontaktieren darf und was Friends von dir sehen.', defaultValue: 'Wer dich kontaktieren darf und was Friends von dir sehen.',
@@ -262,9 +270,10 @@ export function SettingsPage() {
onChange={(v) => void patchProfile({ allowDmsFromStrangers: v })} onChange={(v) => void patchProfile({ allowDmsFromStrangers: v })}
/> />
</Section> </Section>
)}
{activeTab === 'notifications' && (
<Section <Section
id="notifications"
title={t('app:settings.section_notifications', { defaultValue: 'Benachrichtigungen' })} title={t('app:settings.section_notifications', { defaultValue: 'Benachrichtigungen' })}
description={t('app:settings.section_notifications_hint', { description={t('app:settings.section_notifications_hint', {
defaultValue: 'Töne für eingehende Nachrichten und Anrufe.', defaultValue: 'Töne für eingehende Nachrichten und Anrufe.',
@@ -277,9 +286,10 @@ export function SettingsPage() {
<RingtoneSettings disabled={busy} /> <RingtoneSettings disabled={busy} />
</SubSection> </SubSection>
</Section> </Section>
)}
{activeTab === 'voice' && (
<Section <Section
id="voice"
title={t('app:settings.section_voice', { defaultValue: 'Sprache & Anrufe' })} title={t('app:settings.section_voice', { defaultValue: 'Sprache & Anrufe' })}
description={t('app:settings.section_voice_hint', { description={t('app:settings.section_voice_hint', {
defaultValue: 'Mikrofon, Audio-Qualität und Hotkeys für Anrufe.', defaultValue: 'Mikrofon, Audio-Qualität und Hotkeys für Anrufe.',
@@ -307,9 +317,10 @@ export function SettingsPage() {
<CallE2EEControls /> <CallE2EEControls />
</SubSection> </SubSection>
</Section> </Section>
)}
{activeTab === 'screen-share' && (
<Section <Section
id="screen-share"
title={t('app:settings.section_screen_share', { defaultValue: 'Bildschirmfreigabe' })} title={t('app:settings.section_screen_share', { defaultValue: 'Bildschirmfreigabe' })}
description={t('app:settings.section_screen_share_hint', { description={t('app:settings.section_screen_share_hint', {
defaultValue: 'Auflösung und Bitrate beim Teilen deines Bildschirms.', defaultValue: 'Auflösung und Bitrate beim Teilen deines Bildschirms.',
@@ -317,9 +328,10 @@ export function SettingsPage() {
> >
<ScreenShareControls /> <ScreenShareControls />
</Section> </Section>
)}
{activeTab === 'soundboard' && (
<Section <Section
id="soundboard"
title={t('app:settings.section_soundboard', { defaultValue: 'Soundboard' })} title={t('app:settings.section_soundboard', { defaultValue: 'Soundboard' })}
description={t('app:settings.section_soundboard_hint', { description={t('app:settings.section_soundboard_hint', {
defaultValue: 'Eigene Sounds für Anrufe — verwaltet & abspielbar mit Hotkey.', defaultValue: 'Eigene Sounds für Anrufe — verwaltet & abspielbar mit Hotkey.',
@@ -327,9 +339,10 @@ export function SettingsPage() {
> >
<SoundboardSettings /> <SoundboardSettings />
</Section> </Section>
)}
{activeTab === 'security' && (
<Section <Section
id="security"
title={t('app:settings.section_security', { defaultValue: 'Sicherheit' })} title={t('app:settings.section_security', { defaultValue: 'Sicherheit' })}
description={t('app:settings.section_security_hint', { description={t('app:settings.section_security_hint', {
defaultValue: 'PIN, Recovery-Code und Schlüssel-Reparatur.', defaultValue: 'PIN, Recovery-Code und Schlüssel-Reparatur.',
@@ -337,9 +350,10 @@ export function SettingsPage() {
> >
{profile?.userId && <SecurityCenter userId={profile.userId} />} {profile?.userId && <SecurityCenter userId={profile.userId} />}
</Section> </Section>
)}
{activeTab === 'account' && (
<Section <Section
id="account"
title={t('app:settings.section_account_mgmt', { defaultValue: 'Konto verwalten' })} title={t('app:settings.section_account_mgmt', { defaultValue: 'Konto verwalten' })}
description={t('app:settings.section_account_mgmt_hint', { description={t('app:settings.section_account_mgmt_hint', {
defaultValue: 'Abmelden oder Konto-Aktionen.', defaultValue: 'Abmelden oder Konto-Aktionen.',
@@ -355,6 +369,8 @@ export function SettingsPage() {
{t('app:settings.sign_out')} {t('app:settings.sign_out')}
</button> </button>
</Section> </Section>
)}
</div>
</main> </main>
</div> </div>
</div> </div>
@@ -1262,13 +1278,11 @@ function formatBitrate(kbps: number): string {
} }
function Section({ function Section({
id,
title, title,
description, description,
children, children,
tone, tone,
}: { }: {
id: string;
title: string; title: string;
description?: string; description?: string;
children: React.ReactNode; children: React.ReactNode;
@@ -1276,11 +1290,8 @@ function Section({
}) { }) {
return ( return (
<section <section
id={id}
// scroll-mt offsets the anchor target so the section heading isn't flush
// against the viewport top after a sidebar-link jump
className={ className={
'scroll-mt-8 rounded-2xl border bg-surface-2 p-6 ' + 'rounded-2xl border bg-surface-2 p-6 ' +
(tone === 'danger' ? 'border-rose-500/30' : 'border-line') (tone === 'danger' ? 'border-rose-500/30' : 'border-line')
} }
> >