Compare commits

...

6 Commits

Author SHA1 Message Date
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
byGalax 12e4b597a0 chore(desktop): release v0.18.5 2026-05-16 01:09:54 +02:00
byGalax 5c05afb009 feat(desktop): redesign Settings + fix unreadable bubble text
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
2026-05-16 01:08:28 +02:00
byGalax a636a3c1c1 chore(desktop): release v0.18.4 2026-05-16 01:00:34 +02:00
byGalax c9fe4879e0 fix(shared): stop sending fake install-id as messages.sender_device_id
Task 12 (the AuthContext userKeyState refactor) replaced the per-device
DeviceRecord lookup with a localStorage UUID via ensureInstallId(). That
UUID was then passed straight through to messages.sender_device_id on
INSERT.

The messages_insert_member RLS policy requires sender_device_id to be
NULL OR to match a row in `devices` owned by the caller. The localStorage
UUID matches neither -> 403 -> outbox endlessly retries with "Wiederhole".

Fix: SendMessageParams.senderDeviceId becomes optional, and the message
INSERT coerces undefined to NULL. The column is pure telemetry post-conv-
keys so passing NULL is correct. Existing call sites that hand in
ensureInstallId() still typecheck (string is assignable to string|null|undefined)
but the row is written with NULL until those callers stop passing it.
2026-05-16 00:59:28 +02:00
4 changed files with 347 additions and 136 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@chat-app/desktop", "name": "@chat-app/desktop",
"version": "0.18.3", "version": "0.18.6",
"private": true, "private": true,
"description": "Electron desktop client (Windows / macOS / Linux)", "description": "Electron desktop client (Windows / macOS / Linux)",
"type": "module", "type": "module",
@@ -416,7 +416,14 @@ export function MessageBubble({
</button> </button>
)} )}
{message.plaintext === null ? ( {message.plaintext === null ? (
<span className="italic text-fg-muted opacity-60"> <span
className={
'italic ' +
// Mine = blue/accent bubble → use accent-fg with reduced opacity
// (still meets 4.5:1). Peer = surface-2 grey → muted-fg works.
(mine ? 'text-accent-fg/80' : 'text-fg-muted')
}
>
{t('app:chats.unreadable', { {t('app:chats.unreadable', {
defaultValue: 'Nachricht nicht lesbar', defaultValue: 'Nachricht nicht lesbar',
})} })}
+249 -54
View File
@@ -8,6 +8,18 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Avatar } from '../components/Avatar'; 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 { MicTestSection } from '../components/MicTestSection';
import { NotificationSoundSettings } from '../components/NotificationSoundSettings'; import { NotificationSoundSettings } from '../components/NotificationSoundSettings';
import { RingtoneSettings } from '../components/RingtoneSettings'; import { RingtoneSettings } from '../components/RingtoneSettings';
@@ -99,30 +111,118 @@ export function SettingsPage() {
void patchProfile({ locale }); 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<string>(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 ( 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 flex max-w-3xl flex-col gap-6 px-6 py-8"> <div className="mx-auto grid max-w-6xl gap-8 px-6 py-8 lg:grid-cols-[14rem_minmax(0,1fr)]">
<header className="mb-2"> {/* Sidebar — sticky anchor nav, hidden on narrow screens */}
<aside className="hidden lg:block">
<div className="sticky top-8 space-y-1">
<h1 className="mb-4 px-3 font-display text-2xl font-semibold tracking-tight text-fg">
{t('app:settings.title')}
</h1>
<nav aria-label={t('app:settings.title')}>
{sections.map(({ id, label, Icon }) => {
const active = activeId === id;
return (
// NOTE: must NOT be an `<a href="#id">` — the app uses
// HashRouter where the URL hash IS the route. Anchor links
// would navigate away from /settings instead of scrolling.
// We scroll the section into view programmatically and
// optimistically set activeId so the click feels instant.
<button
key={id}
type="button"
onClick={() => {
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
setActiveId(id);
}
}}
className={
'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
? 'bg-accent/15 text-fg'
: 'text-fg-muted hover:bg-surface-2 hover:text-fg')
}
>
<Icon
className={
'h-4 w-4 shrink-0 ' + (active ? 'text-accent' : 'text-fg-muted')
}
/>
<span className="truncate">{label}</span>
</button>
);
})}
</nav>
</div>
</aside>
{/* Content column */}
<main className="space-y-8">
{/* Mobile-only header (sidebar replaces it on lg+) */}
<header className="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> </header>
{/* Account */} <Section
<Section title={t('app:settings.section_account')}> id="profile"
title={t('app:settings.section_account')}
description={t('app:settings.section_account_hint', {
defaultValue: 'Dein öffentliches Profil und wie andere dich sehen.',
})}
>
<ProfileVisualsControls 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.username')} value={profile?.username ?? '—'} />
<DisplayNameControls patchProfile={patchProfile} busy={busy} /> <DisplayNameControls patchProfile={patchProfile} busy={busy} />
<Row 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>
{/* Startup */} <Section
<Section title={t('app:settings.section_startup', { defaultValue: 'Start' })}> id="appearance"
<AutoStartControls /> title={t('app:settings.section_appearance')}
</Section> description={t('app:settings.section_appearance_hint', {
defaultValue: 'Theme, Sprache und Verhalten beim Systemstart.',
{/* Appearance */} })}
<Section title={t('app:settings.section_appearance')}> >
<ThemeRow /> <ThemeRow />
<SettingRow label={t('app:settings.language')}> <SettingRow label={t('app:settings.language')}>
<div className="inline-flex rounded-lg border border-line bg-surface-3 p-1"> <div className="inline-flex rounded-lg border border-line bg-surface-3 p-1">
@@ -147,10 +247,18 @@ export function SettingsPage() {
})} })}
</div> </div>
</SettingRow> </SettingRow>
<SubGroup>
<AutoStartControls />
</SubGroup>
</Section> </Section>
{/* Privacy */} <Section
<Section title={t('app:settings.section_privacy')}> id="privacy"
title={t('app:settings.section_privacy')}
description={t('app:settings.section_privacy_hint', {
defaultValue: 'Wer dich kontaktieren darf und was Friends von dir sehen.',
})}
>
<Toggle <Toggle
label={t('app:settings.show_read_receipts')} label={t('app:settings.show_read_receipts')}
hint={t('app:settings.show_read_receipts_hint')} hint={t('app:settings.show_read_receipts_hint')}
@@ -167,72 +275,99 @@ export function SettingsPage() {
/> />
</Section> </Section>
{/* Notification sound (new messages) */}
<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', {
defaultValue: 'Töne für eingehende Nachrichten und Anrufe.',
})}
> >
<SubSection title={t('app:settings.subsection_message_sound', { defaultValue: 'Nachrichten-Ton' })}>
<NotificationSoundSettings disabled={busy} /> <NotificationSoundSettings disabled={busy} />
</Section> </SubSection>
<SubSection title={t('app:settings.subsection_ringtone', { defaultValue: 'Klingelton bei Anruf' })}>
{/* Ringtone (incoming custom) */}
<Section title={t('app:settings.section_ringtone', { defaultValue: 'Klingelton' })}>
<RingtoneSettings disabled={busy} /> <RingtoneSettings disabled={busy} />
</SubSection>
</Section> </Section>
{/* Soundboard */} <Section
<Section title={t('app:settings.section_soundboard', { defaultValue: 'Soundboard' })}> id="voice"
<SoundboardSettings /> title={t('app:settings.section_voice', { defaultValue: 'Sprache & Anrufe' })}
</Section> description={t('app:settings.section_voice_hint', {
defaultValue: 'Mikrofon, Audio-Qualität und Hotkeys für Anrufe.',
{/* Voice / Push-to-Talk + Audio Quality + E2EE */} })}
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}> >
<SubSection title={t('app:settings.subsection_audio_device', { defaultValue: 'Audio-Gerät' })}>
<AudioDeviceControls /> <AudioDeviceControls />
<div className="mt-3 border-t border-line pt-3"> </SubSection>
<SubSection title={t('app:settings.subsection_audio_quality', { defaultValue: 'Audio-Qualität' })}>
<AudioQualityControls /> <AudioQualityControls />
</div> </SubSection>
<div className="mt-3 border-t border-line pt-3"> <SubSection title={t('app:settings.subsection_ptt', { defaultValue: 'Push-to-Talk' })}>
<PttControls /> <PttControls />
</div> </SubSection>
<div className="mt-3 border-t border-line pt-3"> <SubSection title={t('app:settings.subsection_hotkeys', { defaultValue: 'Hotkeys' })}>
<div className="space-y-2">
<VoiceHotkeyControls kind="mute" /> <VoiceHotkeyControls kind="mute" />
</div>
<div className="mt-3 border-t border-line pt-3">
<VoiceHotkeyControls kind="deafen" /> <VoiceHotkeyControls kind="deafen" />
</div>
<div className="mt-3 border-t border-line pt-3">
<VoiceHotkeyControls kind="hangup" /> <VoiceHotkeyControls kind="hangup" />
</div>
<div className="mt-3 border-t border-line pt-3">
<VoiceHotkeyControls kind="screenShare" /> <VoiceHotkeyControls kind="screenShare" />
</div>
<div className="mt-3 border-t border-line pt-3">
<VoiceHotkeyControls kind="video" /> <VoiceHotkeyControls kind="video" />
</div> </div>
<div className="mt-3 border-t border-line pt-3"> </SubSection>
<SubSection title={t('app:settings.subsection_call_e2ee', { defaultValue: 'Anruf-Verschlüsselung' })}>
<CallE2EEControls /> <CallE2EEControls />
</div> </SubSection>
</Section> </Section>
{/* Screen-share quality */} <Section
<Section title={t('app:settings.section_screen_share', { defaultValue: 'Bildschirmfreigabe' })}> id="screen-share"
title={t('app:settings.section_screen_share', { defaultValue: 'Bildschirmfreigabe' })}
description={t('app:settings.section_screen_share_hint', {
defaultValue: 'Auflösung und Bitrate beim Teilen deines Bildschirms.',
})}
>
<ScreenShareControls /> <ScreenShareControls />
</Section> </Section>
{/* Security */} <Section
<Section title={t('app:settings.section_security', { defaultValue: 'Sicherheit' })}> id="soundboard"
title={t('app:settings.section_soundboard', { defaultValue: 'Soundboard' })}
description={t('app:settings.section_soundboard_hint', {
defaultValue: 'Eigene Sounds für Anrufe — verwaltet & abspielbar mit Hotkey.',
})}
>
<SoundboardSettings />
</Section>
<Section
id="security"
title={t('app:settings.section_security', { defaultValue: 'Sicherheit' })}
description={t('app:settings.section_security_hint', {
defaultValue: 'PIN, Recovery-Code und Schlüssel-Reparatur.',
})}
>
{profile?.userId && <SecurityCenter userId={profile.userId} />} {profile?.userId && <SecurityCenter userId={profile.userId} />}
</Section> </Section>
{/* Danger zone */} <Section
<Section title={t('app:settings.danger_zone')}> id="account"
title={t('app:settings.section_account_mgmt', { defaultValue: 'Konto verwalten' })}
description={t('app:settings.section_account_mgmt_hint', {
defaultValue: 'Abmelden oder Konto-Aktionen.',
})}
tone="danger"
>
<button <button
type="button" type="button"
onClick={() => void signOut()} onClick={() => void signOut()}
className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/50 dark:text-rose-300" className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/50 dark:text-rose-300"
> >
<SignOutIcon className="h-4 w-4" />
{t('app:settings.sign_out')} {t('app:settings.sign_out')}
</button> </button>
</Section> </Section>
</main>
</div> </div>
</div> </div>
); );
@@ -1138,21 +1273,81 @@ function formatBitrate(kbps: number): string {
return kbps + ' kbps'; 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 ( return (
<section className="rounded-2xl border border-line bg-surface-2 p-5"> <section
<h2 className="mb-4 text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted"> 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={
'scroll-mt-8 rounded-2xl border bg-surface-2 p-6 ' +
(tone === 'danger' ? 'border-rose-500/30' : 'border-line')
}
>
<header className="mb-5 border-b border-line pb-4">
<h2 className={'font-display text-lg font-semibold ' + (tone === 'danger' ? 'text-rose-500 dark:text-rose-300' : 'text-fg')}>
{title} {title}
</h2> </h2>
<div className="space-y-3">{children}</div> {description && (
<p className="mt-1 text-xs text-fg-muted">{description}</p>
)}
</header>
<div className="space-y-4">{children}</div>
</section> </section>
); );
} }
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 (
<div className="space-y-2 rounded-xl border border-line/60 bg-surface-3/40 p-4">
<h3 className="text-[11px] font-semibold uppercase tracking-[0.1em] text-fg-muted">
{title}
</h3>
<div className="space-y-3">{children}</div>
</div>
);
}
// 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 (
<div className="space-y-3 border-t border-line pt-4">{children}</div>
);
}
function Row({
label,
value,
mono,
icon,
}: {
label: string;
value: string;
mono?: boolean;
icon?: React.ReactNode;
}) {
return ( return (
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<dt className="text-sm text-fg-muted">{label}</dt> <dt className="flex items-center gap-1.5 text-sm text-fg-muted">
{icon}
{label}
</dt>
<dd <dd
className={ className={
'max-w-[60%] truncate text-right text-sm text-fg ' + 'max-w-[60%] truncate text-right text-sm text-fg ' +
+12 -3
View File
@@ -91,7 +91,11 @@ export interface SendMessageParams {
conversationId: string; conversationId: string;
plaintext: string; plaintext: string;
senderUserId: string; senderUserId: string;
senderDeviceId: string; // Optional now: post-conv-keys this is pure telemetry. The 0.18 builds
// started passing a localStorage UUID that doesn't exist in the devices
// table; messages.sender_device_id RLS then 403'd every insert. Senders
// pass null (or an actually-registered device id, if they have one).
senderDeviceId?: string | null;
senderPrivateKey: Uint8Array; senderPrivateKey: Uint8Array;
replyToId?: string; replyToId?: string;
// Optional encrypted attachments — their handles are already materialised // Optional encrypted attachments — their handles are already materialised
@@ -124,7 +128,12 @@ export async function sendEncryptedMessage(params: SendMessageParams): Promise<C
const insertPayload: Record<string, unknown> = { const insertPayload: Record<string, unknown> = {
conversation_id: params.conversationId, conversation_id: params.conversationId,
sender_id: params.senderUserId, sender_id: params.senderUserId,
sender_device_id: params.senderDeviceId, // ALWAYS null until we re-introduce a real per-install devices row.
// Desktop callers currently pass a localStorage UUID (ensureInstallId)
// which doesn't exist in the devices table; the messages_insert_member
// RLS policy then 403s because the id can't be proven to belong to the
// caller. NULL satisfies the policy ("sender_device_id IS NULL OR …").
sender_device_id: null,
ciphertext: bytesToPgHex(cipher.ciphertext), ciphertext: bytesToPgHex(cipher.ciphertext),
nonce: bytesToPgHex(cipher.nonce), nonce: bytesToPgHex(cipher.nonce),
key_version: handle.keyVersion, key_version: handle.keyVersion,
@@ -174,7 +183,7 @@ export interface EditMessageParams {
// Re-encrypts the message body with the conv-key and updates the row. // Re-encrypts the message body with the conv-key and updates the row.
// Server-side trigger enforces 24h window + sender-only rule. // Server-side trigger enforces 24h window + sender-only rule.
export async function editEncryptedMessage( export async function editEncryptedMessage(
params: EditMessageParams & { senderUserId: string; senderDeviceId: string }, params: EditMessageParams & { senderUserId: string; senderDeviceId?: string | null },
): Promise<void> { ): Promise<void> {
const ownCtx: OwnUserCtx = { const ownCtx: OwnUserCtx = {
userId: params.senderUserId, userId: params.senderUserId,