initialTopMostItemIndex was a plain index (top-aligned), so react-virtuoso painted with estimated row heights then corrected scrollTop after measuring the real (taller) dynamic bubbles — a visible jump on every chat switch. Use { index: 'LAST', align: 'end' } to pin the bottom edge instead, matching react-virtuoso's canonical chat pattern; the restore-to-saved-row path stays align: 'start'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audit of write-actions revealed that send (and edit via realtime UPDATE) are
already optimistic via local state insertion in `useConversationMessages`,
and friend nicknames are pure-local localStorage. Five user-write actions
were waiting on the ~100-200 ms server roundtrip + realtime echo:
* Toggle mute (`setConversationMutedUntil`)
* Toggle mentions-only (`setConversationMentionsOnly`)
* Toggle archive (`setConversationArchived`)
* Pin / unpin message (`pinMessage` / `unpinMessage`)
* Revoke device (`revokeDevice` RPC)
All five now flip local state synchronously and roll back on failure. The
existing realtime subscriptions reconcile canonically (no-op when the
optimistic patch already matches the server row), so this is purely a UX
latency improvement — no protocol or persistence changes.
Reactions (`toggleReaction` / `voteExclusive`) were intentionally skipped
this round: rollback semantics for the exclusive-vote path with multiple
sequential awaits are messy enough to warrant a dedicated pass.
Switches the ConversationPage chat list from a full O(N) render to
windowed rendering via react-virtuoso. On long histories only the
visible rows (plus a 400px overscan buffer) live in the DOM, ending the
scroll jank and layout thrashing that hit conversations with >500
messages.
Preserved behaviors:
- Newest message visible on open via initialTopMostItemIndex.
- Auto-scroll on send via a pending-count-based effect (the old
setStickToBottom + useLayoutEffect pattern doesn't apply now that
Virtuoso owns the scroll element).
- Realtime auto-follow only when scrolled to bottom (followOutput).
- 'New messages while away' counter + 'jump to newest' pill via
atBottomStateChange.
- Pinned-message / reply / search jumps via virtuosoRef.scrollToIndex;
expands displayCount on the fly if the target is outside the
rendered slice. Flash highlight unchanged.
- Load-older infinite scroll via Virtuoso startReached (replaces the
IntersectionObserver-on-sentinel pattern).
- Per-conversation position memory now keys on row index instead of
pixel scrollTop (the latter isn't meaningful under virtualization).
Also wires the PinnedMessagesPanel onJump callback (previously a TODO
that just closed the panel) into jumpToMessage, since virtualization
made the smooth-scroll-from-pinned UX easy to deliver as a side
benefit.
Adds a whiteboard branch to the MessageBubble ternary chain that renders
a card with an icon, label, and Öffnen button dispatching the
chatapp:open-whiteboard CustomEvent. ConversationPage now listens for
that event via a useEffect and calls setOpenWhiteboardId to open the
modal.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Hover an image attachment in the composer to reveal a pencil-edit button;
clicking it opens ImageAnnotator and Save replaces the File in attachments[].
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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
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.
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.
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>
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>
Swaps hardcoded brand-/white-/neutral- utilities for the accent / surface
/ fg / line / fg-muted tokens so light-mode and theme overrides behave
correctly. Also moves focus rings from `focus:` to `focus-visible:`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New voiceHotkeys.ts storage module. Stores per-action bindings with
modifier flags (Ctrl/Shift/Alt) so Discord-style chords like
Ctrl+Shift+M work. Defaults match Discord — Ctrl+Shift+M mute,
Ctrl+Shift+D deafen — but ship disabled to avoid surprise collisions.
- globalShortcut.ts grows registerGlobalShortcutPress /
unregisterGlobalShortcut helpers that accept pre-formatted Tauri
accelerator strings, since voiceHotkey chords can't be expressed by
the existing codeToShortcut path (PTT-only single-key).
- CallContext registers the chords OS-wide while a call is active
(connected | reconnecting) so the hotkeys work from any focused
window. A window-keydown fallback handles the non-Tauri / denied
registration case. Both unregister on call end.
- SettingsPage adds VoiceHotkeyControls (mute + deafen variants)
with a chord-capture button that waits past modifier-only presses
and binds to the first real key. Labels render as "Ctrl+Shift+M"
consistently with the in-call PTT hint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Attachments
- Video: native <video controls>, decrypted blob, metadata preload
- PDF: collapsible <object> viewer with download + preview toggle
- Generic: file card with lazy decrypt, mime-to-extension map
- MessageBubble dispatch: audio/image/video/pdf/generic by mime
- Composer accept widened from image/* to any; AttachmentPreview renders
non-images as file cards
User status
- usePeerPresence returns { state, statusMessage } and tracks both fields
via realtime updates
- ConversationHeader subtitle shows custom status_message when peer is
online/idle/dnd (with message set); falls back to localized presence
label; always "Offline" when state === 'offline'
- UserBar: status_message input in dropdown (max 128 chars, save on
blur/Enter), subtitle uses same precedence as peer view
- AuthContext: auto-flip offline → online on session mount; best-effort
offline on beforeunload/pagehide; respects explicit idle/dnd/invisible
- i18n: presence.status_placeholder (DE + EN)
DND
- CallUI: incoming ring suppressed when own presence === 'dnd' (outgoing
rings stay audible — user-initiated)
- CallContext: presenceRef mirrors profile.presenceState, incoming-call
and missed-call OS notifications skipped under DND
- ConversationsContext already gated message tone + notify
Drag/drop + paste
- Page-root drag handlers feed ingestFiles; overlay shown while dragging
- Textarea onPaste extracts clipboard image items
@mentions in groups
- MentionAutocomplete: keyboard-driven dropdown, arrow/enter/tab/esc
- Composer onChange parses trailing @token, auto-open
- renderBodyWithMentions highlights @username tokens in bubbles
Link previews
- Migration 20260421000003_link_previews.sql (cache table, auth read,
service-role write via edge function)
- Edge function og-preview: POST {url}, fetches HTML (6s timeout, 1MB
cap), regex-parses OG/twitter/description, upserts cache
- useLinkPreview hook with in-memory cache + inflight dedup
- LinkPreviewCard rendered from first URL in message body
Emoji picker
- Built-in curated set (~250 emojis) across five categories
- Search by keyword/emoji char/category, recent list persisted in
localStorage
- Trigger button next to + and voice buttons in composer
Soundboard + ringtone + mic pipeline
- Pulled in (user-authored) SoundboardManagerDialog/Panel/Settings,
RingtoneSettings, mic pipeline + hotkeys + storage modules
- AuthContext imports updateOwnProfile for presence auto-flip
Fixes
- AttachmentAudio min-width so bubbles don't collapse to 0px on peer
side
- Focus flicker: visibility/online wake refresh throttled to 30s,
focus listener dropped, loading flag only on first fetch
- Voice messages: MediaRecorder → encrypted attachment, custom waveform
player via OfflineAudioContext, 60s limit + live mic-level meter
- Offline message queue: localStorage outbox, exponential backoff retries,
optimistic pending bubble with retry/discard
- Delivery indicator: message_deliveries table + RLS (reciprocal receipts),
✓ / ✓✓ / ✓✓-blue tick states, group-aware (all members must ack)
- Per-participant volume slider in calls via right-click tile menu,
persisted to localStorage, applied to attached audio elements
- Group call scaling: grid up to 12 tiles with pagination,
active-speaker auto-promotion in fullscreen
- Push notifications scaffolding: service worker, VAPID subscription
registration, notify-push edge function skeleton
- Backup recovery code: 24-char base32 code (~120 bits entropy) as
alternative decrypt path, restore UI with mode toggle
- Admin panel: conversations list, audit log (admin_audit_log table +
admin_log_action RPC), audit entry on user flag toggle
- Search v2: sender filter, attachment-only toggle, date range
- Reactions pop animation (scale 0.4→1.15→1 on count change)
- Message list windowing (150 default, expand via IntersectionObserver)
- Stub cleanup: removed dead ScreenshareStub from CallParticipantTile
Fixes:
- Focus-triggered flicker: dropped window.focus listeners in three spots,
throttled visibilitychange/online wake-refreshes to 30s, keep existing
data visible during background re-syncs (no more spinner on every click)
- Voice attachment audio element collapsed to 0px on peer side — now
forces 280px min-width on bubble
Migrations (push required):
20260421000001_message_deliveries.sql
20260421000002_admin_audit_log.sql
Server TODO:
VAPID keys + notify-push edge function deploy
Audio device selection:
- audioSettings: persisted inputDeviceId + outputDeviceId
- CallContext: uses stored input deviceId on mic enable, new
setAudioInputDevice / setAudioOutputDevice actions that hot-swap
without reconnect. Output swap applies HTMLMediaElement.setSinkId
to every attached remote-audio element (LiveKit's switchActiveDevice
only tracks elements it attached itself)
- SettingsPage: new "Mikrofon" + "Ausgabegerät" selects with
enumerateDevices, devicechange listener, permission-probe button.
setSinkId-unsupported fallback is messaged but non-blocking
Fullscreen:
- FullscreenCall was absolute inset-0 z-40 which trapped it inside the
<main> pane — sidebar + chat-list stayed visible. Switched to
fixed inset-0 z-[60] so the call overlays the whole window
Discord-style
- ScreenShareViewer fullscreen: CSS-only toggle (native Fullscreen API
unreliable under Tauri WKWebView), portalled to document.body when
active so no ancestor stacking context can clip it. Esc exits
ActiveCallBanner:
- cleanup effect returned early when presence was entirely empty,
leaving the "1 im Raum" fallback stuck after both peers left. Now
schedules dismissLastCall as soon as othersIn.length === 0, with a
3s grace window to absorb presence re-sync flicker
Bump tauri version 0.6.0 -> 0.7.0