The old code registered every enabled hotkey through Electron globalShortcut
API, which captures system-wide. Setting M as mute meant m could not be
typed in any other app. Now the OS-level registration only happens when
binding.global === true; otherwise the existing window-keydown listener
handles it.
The inner cards (Nachrichten-Ton / Klingelton / Audio-Gerät / Hotkeys /
…) looked like they had a stark white outline. The cause: --color-line
is a semi-transparent white token, and applying the `/60` alpha modifier
in `border-line/60` overrides the original alpha — so the inner border
ended up brighter than the outer Section's normal `border-line`.
Drop the border entirely. Background tint + caps heading is enough
grouping signal in a tab-pattern panel.
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.
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.
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
Root cause of "alle Nachrichten verschlüsselt + kann nicht schreiben":
uploadUserKeyBlob (called by setupNewUserIdentity, changePin and
regenerateRecoveryCode) routed through reset_user_key, which DELETES
every conversation_keys row addressed to the user or one of their
devices. So setting a PIN destroyed every legacy bundle BEFORE the
migration could re-wrap them. The user ended up with user_keys set,
zero un-migrated bundles, no decryption, no send.
Fixes shipped:
* supabase/migrations/20260516000001_user_key_rpcs_v2.sql
- upsert_user_key: same UPSERT, NO delete. Used everywhere except
"Identität zurücksetzen" (which keeps reset_user_key on purpose).
- rotate_conv_key: bumps active_key_version atomically and inserts
a fresh batch of bundles (per-user + per-device fallback).
* shared/auth/userKey.ts: uploadUserKeyBlob now calls upsert_user_key.
* shared/chat/convKeys.ts: new rotateConvKey() that wraps the fresh
conv-key for every member's user_keys (preferred) and falls back to
each member's per-device public_key for peers still on 0.17.x.
* shared/chat/convKeys.ts: getOrCreateConvKey auto-triggers rotate
when the user has no recipient_user_id row at the active version
but rows exist (the deadlock case). Existing outbox retries drain
on their own once the rotate completes — no manual button.
* desktop/MessageBubble.tsx: "...cannot decrypt" is now a softer,
German "Nachricht nicht lesbar" so users don't think the app
crashed when historical messages can't be unwrapped.
The 0.18.1 fix relied on an existing-device + present-stronghold-key match.
That fails for users who:
- had multiple device registrations and only retain the latest device's
private key in the local vault
- had a vault wipe / fresh OS install at some point
- have device rows that vanished server-side but keys still locally
Migration now scans conversation_keys for distinct un-migrated
recipient_device_ids visible to the user (RLS-filtered) and probes the
stronghold for each, regardless of whether the server still lists that
device. Result struct surfaces attempted/migrated/noKey/decryptFail/rpcFail
counters; SecurityCenter shows them via a new "Migration erneut ausführen"
button so users can self-diagnose without DevTools.
Also adds [crypto-migration] console.info breadcrumbs at every decision
point so a single F12 shows what happened.
PostgREST translates .eq('col', null) to `col = NULL` which is always false
in SQL. The migration silently returned zero rows -> setupNewUserIdentity
fired but re-wrapped nothing -> users could set a PIN but every send threw
'Awaiting key'. Switching to .is('col', null) emits `col IS NULL` and the
migration finally finds its work.
Also makes the migration trigger idempotent and re-fires it on:
- every successful loadOrUnlockUserKey
- AuthContext startup when the user-key is already cached
so users stuck on 0.18.0 auto-recover the moment they install 0.18.1.
PinInput: focused + active-slot now show a brand-coloured ring, glow, and
a blinking caret so users see where the next keystroke lands.
Drops the always-on 'Sprach-Channel' banner. The preview panel renders
only when peers are in the active call (1:1 and group identical).
Calls are still started via the topbar phone icon.
devices rows no longer carry public_key for crypto purposes. The whole
per-device key API surface (provisionNewDevice, loadDevicePrivateKey,
saveDevicePrivateKey, forgetDevicePrivateKey, restoreDeviceFromServerRecord)
is removed; registerDevice now records {name, platform} only. SQL drops the
NOT NULL on devices.public_key so future telemetry rows can omit it.
Note: SQL not applied locally - push via pnpm prod:migrate when ready.
When a conversation opens, the local client checks every accepted member
for a recipient_user_id bundle on the active key version. Members without
one get a best-effort wrap from the local conv-key handle. This closes
the legacy migration gap where peer B couldn't read because no one had
yet wrapped the new per-user conv-key for them.
Drops the manual backup-string flow; replaces it with PIN change,
recovery-code regeneration, and identity reset (all sealed via the new
user_keys table).
Replaces the per-device DeviceRecord lookup with a per-user discriminated
union (loading | needs-setup | needs-unlock | unlocked). Heartbeat block
deleted (telemetry no longer device-bound); webPush keyed by install-id.
Renames DecryptParams.ownDeviceId to ownUserId so decryptMessages actually
looks up bundles by user. Sweeps remaining OwnDeviceCtx and
loadDevicePrivateKey consumers in the desktop app to use cachedUserKey
from userIdentity. Files scheduled for deletion in later tasks
(BackupExportDialog, DeviceRestore, BackupRestoreDialog, BackupPromptBanner,
deviceBackup, DeviceRegistration) are left untouched.
`appVersion: process.env.npm_package_version ?? '0.0.0'` only worked in
dev — pnpm sets that env var while running its lifecycle scripts. In
the packaged Electron build npm_package_version is unset, so every
installed user saw `v0.0.0` on the Changelog page and the version badge
permanently flagged them as "Update verfügbar" against their own
actually-current version.
Replace with a static `import pkg from '../package.json'` so Vite
inlines the version string into the preload bundle at build time. The
release script bumps package.json before electron-builder runs, so the
inlined value always matches the freshly-released version.
resolveJsonModule + esModuleInterop are already on in tsconfig.node.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both right-hand panels used absolute inset-y-0 right-0 and floated on
top of the conversation, hiding the messages directly underneath the
panel and looking unlike Discord's actual layout. Restructure:
* MediaFilesDrawer: drop absolute/z-index/shadow chrome, become a
static flex column (w-[380px] shrink-0) with a left border. Internal
layout unchanged.
* GroupInfoPanel: same treatment (w-[320px] shrink-0). Dropped the
backdrop-blur and slide-up animation that only made sense as a modal.
* ConversationPage: wrap the chat content (voice rail, in-call panel,
messages list, drag-overlay, input form) in a new
`flex min-w-0 flex-1 flex-col` chat-column, and make that column a
sibling of the drawers inside a new `flex flex-1 flex-row` row. The
conversation header + search bar stay full-width above the row.
Result: opening a drawer narrows the chat column instead of covering
it, matching Discord's behaviour. The chat-column wrapper also carries
the `relative` anchor previously held by the outer wrapper so the
drag-and-drop overlay positions correctly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In group conversations the "Outgoing/Incoming/Missed call" system pill
gave no clue WHO triggered the event — fine in a 1:1 where the only two
players are obvious, useless in a group with three+ members. Discord
puts the caller's name in the pill; mirror that.
CallEventRow now takes a senderDisplayName prop (plumbed through from
MessageBubble) and switches non-own labels to the name-aware variants:
* ended + !mine + name → "{name} hat einen Anruf gestartet"
* missed + !mine + name → "Verpasster Anruf von {name}"
* declined + !mine + name → "Anruf von {name} abgelehnt"
Own events (mine) stay generic ("Outgoing call" / "No answer") since
the user already knows they were the initiator. Fallback path without
a name keeps the previous generic labels so nothing regresses if the
sender is unresolvable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lightbox was previously a file-private component inside AttachmentImage
(used for enlarging chat image attachments). Extracted to a standalone
components/Lightbox.tsx so other surfaces can reuse the same dialog
without duplicating Esc/backdrop/body-overflow plumbing.
In SettingsPage's profile live-preview, the round avatar overlapping the
banner is now wrapped in a transparent button that opens the Lightbox
with the full-resolution avatar URL on click. Cursor switches to
zoom-in. Disabled when the user only has the initial-letter placeholder
(nothing meaningful to enlarge). Native button chrome (border, padding,
button-face background) is reset to keep the avatar circle's appearance
unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The scroll-position memory introduced in 0.17.2 still produced a visible
"chat appears at the top then jumps" frame when switching back into a
conversation. Cause: both scroll-affecting effects (auto-bottom on new
messages, restore on chat re-entry) used useEffect, which fires AFTER
the browser paints the freshly-committed DOM. So users saw scrollTop=0
for one frame before the effect ran and corrected it.
Switching both to useLayoutEffect moves the scroll write into the same
commit phase as the message-list DOM update, so the very first paint
already shows the correct position — single paint, no flicker.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small UX polishes:
1. Switching between chats no longer slams you to the bottom. Each
conversation's scroll position (pixel offset + stickToBottom flag)
is remembered in a module-scoped Map for the lifetime of the
renderer process. Discord-style: leave Chat A scrolled up, peek at
another conversation, come back — same spot you were reading.
Chats left at the bottom keep auto-following new messages on return.
Reload resets everything (session-only, no localStorage).
The restore runs once messages.length > 0 to avoid the browser
clamping scrollTop to a near-zero scrollHeight before the message
list has rendered. A small isRestoringRef guard prevents the
programmatic scroll event from immediately overwriting the saved
position with a clamped value.
2. Changelog page now shows a version badge in the header that compares
the installed app version against entries[0].version from the
server-side changelog feed. Three states:
* `vX.Y.Z · aktuell` (emerald) — installed matches latest
* `vX.Y.Z · Update verfügbar` + `neueste: vA.B.C` (amber) — outdated
* `vX.Y.Z` neutral — installed is ahead of the published feed
(dev/test builds)
Semver compare is integer-major.minor.patch with a graceful
garbage-fallback so a malformed version string doesn't false-flag
a current install as outdated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>