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.
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>
Equal-grid cells no longer set aspect-video — on wide chat panels this
forced cell height = width × 9/16 (~400px on a 700px panel) which pushed
the row past the section's max-h and ate the controls bar below. n>=2
cells now fill grid tracks normally via auto-rows-fr; the solo case
(n=1) keeps a 16:9 silhouette via aspect-video + max-w + justify-self-
center so a single-user-alone-calling view doesn't stretch into a
full-width slab. Same change applied to the fullscreen-grid path plus
+16px bottom-padding (pb-28) so audio-only avatars' name chip clears
the floating controls bar.
Docked stage strip thumbs (focus + bento) switch from aspect-video
shrink-0 to flex-1 min-w-[200px] max-w-[460px] so 2-3 thumbs share the
row width evenly under the share above, instead of clinging to the left
edge with dead space to the right. Fullscreen-cinema strip keeps the
small aspect-video thumbs the user explicitly approved.
ScreenShareViewer gains a hideFullscreenToggle prop; cinema mode passes
it via a new `cinema` prop on TileRender so the in-share fullscreen icon
doesn't visually collide with FullscreenCall's strip-hidden toggle at
the same top-right corner.
docs/superpowers/specs + plans for the Discord-style tile handling
workstream are committed alongside the implementation that completed it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ScreenSharePickerModal now exposes Auflösung (Auto · 720p · 1080p · 1440p
· 4K) and FPS (30 · 60) as separate pill rows instead of bundled quality
presets — users can pick "1440p · 30 fps" or "4K · 30 fps" which the old
preset list didn't surface. The underlying screenShareSettings framerateOverride
slot already existed; the modal just stopped resetting it to null on every
start and now plumbs the chosen FPS through to startScreenShare.
Cinema-mode fullscreen on Windows had two defects:
1. Maximized → fullscreen left the taskbar drawn on top of the window
because DWM kept the maximized work-area constraints. We now unmaximize
first so DWM recomposes cleanly and setFullScreen actually covers the
whole monitor including the taskbar strip.
2. Esc out of cinema came back as a small floating window even when the
user had been maximized before clicking the Vollbild button — the
unmaximize from (1) was never undone. We now memo the pre-fullscreen
maximized flag per window-id and call win.maximize() once the
leave-full-screen event has fired.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After 0.16.2 some users saw the approval banner stack up to 6+ entries
on first launch — every old device they ever registered (Tauri-era,
test installs, dev builds) showed up because the only "already legit"
filter was `created_at <= ownDevice.created_at`. That fails when own
device is restored from Backup (older than every other entry) or when
the user accumulated installs around the migration window.
Add a semantic check: if a device already has at least one row in
`conversation_keys` (recipient_device_id), it has been wrapped before
and is by definition not awaiting approval. Treat as approved silently.
Bulk query against the candidate IDs, no N+1.
Plus UX: when more than one request is pending, render a sticky header
with a count and "Alle ablehnen" button so users with stale piles can
clear them in one click.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Disable the previous auto-share of conversation keys to newly-registered
devices: a stolen password / new device registered by an attacker no
longer automatically grants history access. Backup-Restore (which
restores the old device-id) still opens existing wraps as before.
Phase 1 of the approval replacement:
- New `lib/deviceApproval.ts`: realtime listener for `devices` INSERT,
surfaces a pending list, persists approve/deny decisions in
`chatapp.approvedDeviceIds` / `chatapp.dismissedDeviceIds`. Filters the
initial fetch by created_at > own-device's created_at so a freshly
installed client doesn't try to "approve" pre-existing devices.
- New `components/DeviceApprovalBanner.tsx`: bottom-right Discord-style
banner per pending request with Genehmigen / Ablehnen actions; reuses
`wrapForOneDevice` from conversationKeySync to fan out conv-keys.
- AppShell mounts both the listener and the banner.
Plus dev userData isolation in main.ts: when running unpackaged, append
`-Dev` to the userData path so `pnpm dev` runs side-by-side with the
installed packaged build instead of colliding on the single-instance
lock. Window title also distinguished as "ChatApp (Dev)".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Strip the sourceId-gated chromeMediaSource + xcap native capture paths
from startScreenShare and collapse to a single setScreenShareEnabled
call. Neither of the bypassed paths produced smooth frames in WebView2:
chromeMediaSource: 'desktop' is an extension-only Chromium constraint
and throws outside extension origins, and the xcap JPEG-over-IPC
fallback couldn't sustain 30fps at 1080p on a single main-thread.
setScreenShareEnabled goes through Chromium's native getDisplayMedia
capture, which is the only path that gets HW-accelerated frames into
the WebRTC encoder from WebView2.
Audio continues via the WASAPI loopback module — getDisplayMedia can't
grab system sound in WebView2 without desktop-capture entitlements
Chromium reserves for extensions. The audio track's teardown chains to
the ScreenShare video track's 'ended' event so the Windows stop-share
overlay kills both sides in lockstep.
ScreenSourcePicker is now a quality + audio chooser only; the
thumbnail grid disappears because custom source IDs don't round-trip
through WebView2, and a custom picker in front of the OS picker just
means the user picks twice.
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>
Hooks the custom screen-share picker up to a native WASAPI loopback
capture so "Mit System-Sound" no longer falls back to the OS picker on
Windows. Rust side opens the default render endpoint, channels 48 kHz
f32 stereo to an AudioWorklet, which feeds a MediaStreamDestination for
LiveKit to publish as ScreenShareAudio. Ring buffer sized for latency
(80 ms target, drop-to-target on overflow) and the AudioContext is
resumed eagerly so initial burstiness can't pile up.
Adds a temporary attachTrack:audio diagnostic log to confirm source
tagging matches between old and new clients.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The real reason every thumbnail card stayed on the placeholder was a
reference-aliasing bug in the flush closure. `const batch = pendingUrls`
captured the same object; `delete (pendingUrls)[k]` for each key then
emptied `batch` too, because they were the same reference. By the time
`setThumbnailUrls(prev => ({ ...prev, ...batch }))` ran, batch was {}
and the state never picked up any URL — every card rendered the empty
placeholder icon.
Fixed by aliasing first, then replacing pendingUrls with a fresh empty
object (let instead of const on the outer binding). The cloned `batch`
retains its entries for the spread; any new arrivals during the commit
land in the new empty pendingUrls and coalesce into the next frame.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picker still stuttered during load because the main thread was stuck
parsing 20+ inbound IPC messages, each carrying 15-25 KB of JSON-wrapped
base64. Two changes compound to fix this:
1. Binary IPC. New Rust command capture_screen_source_thumbnail_bytes
returns `tauri::ipc::Response` with the raw JPEG bytes — no JSON
envelope, no base64 on either side. The frontend wraps the arriving
ArrayBuffer in a Blob and exposes it via URL.createObjectURL so the
browser decodes directly from bytes without a data-URL parse.
Empirically drops per-arrival main-thread work from ~10-15 ms to
~1-2 ms.
2. rAF-batched thumbnail state updates. Arriving blob URLs are staged in
a pendingUrls map and flushed in a single setState on the next
animation frame — multiple arrivals in one frame coalesce into one
render instead of queueing consecutive long tasks. Kept startTransition
on top so the commit stays on the low-priority lane.
Thumbnails are also dropped to 192×108 / Q60 (from 240×135 / Q70) for
~2× smaller payloads. Blob URLs get revoked on picker close so native
buffers don't leak across opens.
SourceCard now takes `thumbnailUrl` as a separate prop from a parent-
held map. Keeps source object references stable so React.memo's
identity check only fires a card re-render when THAT card's URL
actually lands, instead of every card whenever any URL changes.
Next session: WASAPI loopback for system-audio capture in native share.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous pass moved to JPEG + startTransition but the grid still froze.
Root causes that survived:
1. React.memo was broken — the parent re-created the inline
`onClick={() => onSelect(src.id)}` arrow on every render, so memo's
reference check always triggered a fresh render on every card even
though nothing visible had changed. Fixed by passing `onSelect` as a
stable prop and constructing the click handler inside the memoized
child.
2. 20 data-URL `<img>` sources getting decoded more or less at once gave
the compositor enough work to make scroll feel laggy. `decoding="async"`
punts decode to the browser's image thread; `loading="lazy"` skips it
entirely for cards outside the viewport.
3. Concurrency at 4 was still high enough for Windows GDI BitBlt /
PrintWindow to contend for the desktop compositor — the whole Tauri
window stuttered because the OS-level screen capture was saturating
the graphics pipeline. Dropped to 2 concurrent captures; total load
takes a touch longer but the picker stays interactive throughout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The picker still felt frozen while thumbnails were streaming in because
each result was both (a) large — PNG @ 320×180 landed at 60-150 KB
base64 — and (b) triggering a high-priority React re-render of the whole
grid. Three fixes together restore interactivity:
- Thumbnails encoded as JPEG @ Q70 at 240×135 instead of PNG @ 320×180.
Drops the typical payload from ~100 KB to ~20 KB, so IPC JSON-parsing
on arrival is 5× faster.
- SourceCard wrapped in React.memo so only the card whose thumbnail just
landed re-renders. Previously one new thumbnail caused all ~20 cards
to re-evaluate their props.
- setSources updates run inside startTransition so scroll / click events
stay on the high-priority lane while the grid backfills.
Also: when the user enables "Sound mit übertragen" AND has a source
picked, the picker now surfaces an inline amber note explaining that
the OS picker will appear for the audio capture path. Matches the
existing console info log but is visible pre-click so users don't
experience it as a bug.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Volume crash:
- setParticipantVolume / setScreenShareVolume propagated values up to 2.0
(200%) to the per-track GainNode, but also called applyToAttachedElements
which set the raw HTMLAudioElement.volume — that property is hard-clamped
to [0, 1] and throws IndexSizeError above 1. Clip the element-path apply
at 1.0. WebAudio GainNode keeps doing the actual amplification.
Picker freeze:
- Firing ~20 captureScreenSourceThumbnail invokes in parallel caused
perceptible input freezes while each ~100KB base64 result arrived and
triggered a setState. Bounded the worker pool to 4 concurrent captures
with a queue — overall wall-clock is nearly identical and the grid stays
scrollable / clickable throughout the load.
Native-path diagnostics:
- Previous logs only fired on non-NativeCaptureUnavailable errors, so
users couldn't tell whether the native path was skipped (audio toggle
on, no sourceId) or attempted-and-failed. Added explicit info logs for
each skip reason plus an always-on warn with the underlying error when
the try block throws. Makes the next debug pass on screenshare much
quicker.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picker speed (Phase 1+2):
- screen_sources.rs split into list_screen_sources (metadata only,
returns in ~10ms) + capture_screen_source_thumbnail (single source,
by id). ScreenSourcePicker now shows names + placeholders instantly
and streams thumbnails in as each capture lands. Total wall-clock
is bounded by the slowest source instead of the serial sum.
- enumerate_screen_sources kept as a dead_code fallback so any
rollout regression can switch the frontend back without code loss.
Native capture (Phase 3):
- New src-tauri/src/screen_capture.rs. start_screen_capture spawns a
Rust thread per share that grabs frames via xcap, downscales to the
user's quality preset, JPEG-encodes at Q72, and streams each frame
through a Tauri Channel<FramePayload>. stop_screen_capture signals
the stop flag and joins the worker.
- Worker re-resolves the xcap handle inside the thread because
xcap::Window holds a !Send HWND — passing the source id string
across the thread boundary sidesteps that.
- New lib/screenCapture.ts: decodes each frame into an ImageBitmap,
draws to an offscreen canvas, exposes canvas.captureStream() as the
MediaStream LiveKit publishes. Latest-wins frame queue drops stale
frames when the JS side falls behind the Rust producer. 3s first-
frame timeout so a silently-failing source (locked screen, DRM
window) surfaces as a clean NativeCaptureUnavailable and we fall
back to getDisplayMedia.
- CallContext.startScreenShare takes the native path first when the
picker provided a sourceId and system audio wasn't requested. The
old chromeMediaSourceId attempt and final setScreenShareEnabled
fallback stay in place for the audio case + non-Tauri runtimes.
- stopScreenShare kills the native handle first, then unpublishes any
manually-published ScreenShare/ScreenShareAudio tracks, then falls
back to setScreenShareEnabled(false). disconnectRoom also stops
the handle so we don't leak Rust threads across calls.
Scope note: native path is video-only. System-audio capture needs
WASAPI-loopback (Windows) or ScreenCaptureKit-audio (macOS); until
those are wired, requesting audio in the picker falls through to
the getDisplayMedia path and shows the OS picker for that one case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HTMLMediaElement.volume caps at 1.0, so boosting a quiet peer past
100% needs an explicit GainNode in the output chain. New
remoteAudioPipelines module owns one AudioContext + GainNode per
remote audio track; attachTrack / detachTrack now create and tear
down the pipeline alongside the LiveKit element.
Once a track is on the WebAudio path its direct output is diverted
(createMediaElementSource semantics), so audio.muted / volume can't
drive output anymore. Deafen, watch-state, manual screen-share mute
and per-user volume are collapsed into one effective-gain formula
that gets recomputed on every state flip — the effect subscribes to
both participantVolumes and screenShareVolumes for live slider drags.
Slider ranges updated to 0–200% across:
- ParticipantVolumeMenu (per-user right-click menu)
- ParticipantsPopover (in-call participant list)
- ScreenShareContextMenu (per-share right-click)
Values above 100% render the percentage in amber as a soft hint that
clipping is possible. Clamp in both volume stores extended to [0, 2]
so persisted values survive.
setAudioOutputDevice now additionally routes via
AudioContext.setSinkId (Chrome 115+) for the WebAudio graph; the
HTMLAudioElement.setSinkId fallback stays for older runtimes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the music icon always rendered in the in-call bar, opening a
popover with a "Keine Sounds gespeichert" empty-state. Matches Discord's
pattern better to just drop the button entirely until the user has
something to play — otherwise it reads as a broken control. Live-
subscribes to soundboardStorage so adding/removing sounds mid-call flips
the button in or out without reopening the call. Also closes the popover
automatically if the user clears their last sound while it's open.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rust side:
- New src-tauri/src/screen_sources.rs with an enumerate_screen_sources
command. Uses the xcap crate for cross-platform screen + window
enumeration and capture; PNG thumbnails are letterbox-scaled to fit
320x180 and returned as base64.
- Source ids emit Chromium's internal desktopCapturer format
("screen:<id>:0", "window:<hwnd>:0") so JS can try passing them
straight into chromeMediaSourceId.
- Registered in both invoke_handler branches in lib.rs.
Frontend:
- New lib/screenSources.ts — Tauri command wrapper + thumbnailDataUrl
helper for the picker UI.
- New components/ScreenSourcePicker.tsx — Discord-style grid: sources
grouped under "Bildschirme" / "Fenster", large thumbnail cards with
selection state, quality preset + system-audio toggle in the footer.
"Teilen" button is enabled either way; without a selection it says
"Ohne Auswahl weiter" and falls through to the OS picker.
- Replaces the old form-style ScreenShareDialog entirely (removed).
CallContext wiring:
- startScreenShare now accepts an optional sourceId. When set, it
captures that exact source via getUserMedia's legacy
chromeMediaSourceId constraint and publishes the resulting tracks
manually (video as ScreenShare, audio as ScreenShareAudio). Falls
back to setScreenShareEnabled if WebView2 rejects the constraint,
so users always get a working share even if the direct path fails.
- Track 'ended' listeners unpublish the pub when the OS revokes
capture (close of shared window, OS "stop sharing" banner).
InCallPanel:
- Left-click on the share button now opens the picker instead of
starting with last-saved settings; right-click opens it too. The
picker itself is the 1-click UX.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Watch-gate lifted into CallContext. watchingShareUserIds /
dismissedShareUserIds / screenShareAudioMutedIds as session-only state,
cleared on CallState.idle and on TrackUnsubscribed for each sharer.
Survives layout changes (grid <-> focus <-> fullscreen) without
resetting which the old local-state viewer dropped on remount.
- ScreenShareAudio tracks tagged via data-track-source="screenshare" at
attach-time; initial muted follows watching + manual mute mirrors so
audio never plays before the user clicks "Bildschirm anschauen". Deafen
still wins at the top of the priority chain.
- New screenShareVolumes store (session-only, keyed by participantId).
attachTrack pulls the initial volume from this store for screenshare
audio elements so the context-menu slider takes effect immediately.
- Screen shares are no longer auto-promoted to focus. They render as
equal-size grid tiles like everyone else; user clicks to focus. The
"Bildschirm anschauen" overlay replaces auto-play as the opt-in.
- Dismissed sharer-ids filter out of buildTiles, so "Zuschauen beenden"
really hides the tile until the sharer stops + restarts.
- New ScreenShareContextMenu (portal, Esc / outside-click to close):
volume slider + audio mute toggle when the share has audio + a
destructive "Zuschauen beenden" row. Wired via a dispatcher in
InCallPanel that picks between participant-volume and share-menu
based on tile.kind.
- Fullscreen cinema gets a "Hide participant strip" toggle (top-right,
session-only) so focused content reaches the full viewport when the
bottom thumbnail row would otherwise steal 160px. Fades with the
auto-hide controls; only surfaces when there's a focus + peers to hide.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Default flipped to off so voice doesn't get coloured by browser NS on
first run. Users turn it on explicitly under Settings → Sprache, where
the toggle already lived before the in-call button shipped.
The in-call SparklesIcon button and all the associated wiring
(onToggleNoiseSuppression, noiseSuppression prop, local subscription
in InCallPanel) is removed. The hot-swap UX is preserved: a new
subscribeAudioSettings watcher in CallContext detects noiseSuppression
flips during an active call and re-runs setupMicPipeline so the change
takes effect without rejoining.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- PiP widget now shows a live mm:ss / hh:mm:ss duration instead of the
generic "tippe zum Öffnen" while a call is in progress.
- New ParticipantsPopover — portal-mounted, fixed bottom-right, lists
everyone in the call with avatar, speaking ring, mute/deafen badges
and a per-peer volume slider. Wired to CallControls via the users
button (data-participants-trigger skips the outside-click dismiss
while toggling).
- Non-terminal MicErrorBanner: getUserMedia failures inside joinRoom
used to be silently swallowed by a console.error; they now set a
categorized message (NotAllowedError / NotFoundError / NotReadableError)
on CallContext.micError, render as a rose banner in both docked and
fullscreen modes, and offer a Retry button that calls the extracted
setupMicPipeline without rejoining the room.
- Screen-share toggle is now 1-click using the last-saved preset +
displaySurface. Right-click on the share button still opens the
quality dialog for users who want to adjust before starting.
- Noise-suppression toggle in the control bar (SparklesIcon). Flipping
it updates audioSettings and hot-swaps the mic track via
setAudioInputDevice so the new constraint takes effect without a
rejoin. Mirrors Discord's Krisp button placement.
- Fullscreen auto-speaker now tracks "most recently started speaking"
instead of "exactly one currently speaking", so two people briefly
overlapping doesn't kick the focus back to grid. Tracked in a
prevSpeakers ref against each activeSpeakers diff.
- Fullscreen controls auto-hide after 5s of mouse idle; mousemove /
touchstart bring them back. Pinned visible while any popover
(soundboard / volume-menu / participants / mic-error banner) is open
so users can interact without the chrome fading mid-click.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A — Call core:
- Deafen now implies mute + remembers pre-deafen mic state so un-deafen
restores it (Discord-parity). Peers still see the headphones-off +
mic-off badges in sync via the existing data-channel broadcast.
- Self-join sound fires on the local peer's r.connect() too, not just
on remote ParticipantConnected, so the user gets the "I'm in" cue.
- New CallState.reconnecting holds the UI steady when LiveKit drops the
signaling socket and retries; duration keeps ticking, status label
switches to "Verbinde neu…". Full teardown only on terminal
Disconnected (after LK gives up).
- joinActiveCall falls back to connected after 5s if no peer arrived —
avoids hanging in "Verbinde…" when peers left the room mid-rejoin.
B — Ringtone:
- Oscillator base gain up (incoming 0.22 -> 0.4, outgoing 0.14 -> 0.22)
so the default pattern survives laptop speakers + background music.
- New ringtoneVolume slider in Settings, default 0.9, live-applies to
both the oscillator fallback and the custom-file <audio> element.
C — Participant tile:
- Split the speaking indicator: video tiles get the emerald border +
inset glow; audio tiles rely on the existing avatar pulse. No more
double-chrome when someone talks in grid/focus view.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- lib/crashRecovery.ts: window.onerror + unhandledrejection handlers,
dedupe identical messages within 10s, burst-reload after 8 distinct
errors in 30s
- components/CrashToast.tsx: portal stack in bottom-right, max 3 visible,
auto-dismiss after 7s, per-entry close button
- Wired in main.tsx before bootstrap so crypto/i18n errors are captured,
rendered from App.tsx alongside UpdateToast
Covers the async layer ErrorBoundary misses (realtime handlers, stray
Promises, setTimeout callbacks, LiveKit listeners).
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
Speaking ring:
- Switch useActiveSpeakers from LiveKit's smoothed isSpeaking / server-
batched ActiveSpeakersChanged to Web Audio API AnalyserNode on each
participant's raw audio MediaStreamTrack. Poll 50ms, RMS threshold 0.03,
250ms hold. Feels real-time vs the old ~500ms lag
- Defensive syncProbe on every tick so probes catch up if TrackPublished
missed (local mic publish race on join)
- Universal speaking overlay on tile (3px emerald border + inset glow,
z-10) so video mode shows the ring too, not just audio mode
Screen sharing:
- Separate "screen" tile per sharer so the sharer's avatar tile stays
intact with its speaking ring. Tile.id is kind-prefixed (user:xxx /
screen:xxx) so focus tracking distinguishes them
- New ScreenShareDialog (quality preset + fps override + displaySurface
hint) opens on the share button. startScreenShare / stopScreenShare
actions in CallContext replace the one-shot toggle
- ScreenShareViewer: plain CSS-only fullscreen overlay (Tauri WKWebView
doesn't implement requestFullscreen), always `h-full w-full
object-contain`, Esc exits
Camera:
- toggleCamera action in CallContext tracks isCameraEnabled
- VideoStub renders real <video> srcObject for the participant's camera
MediaStreamTrack; local preview is mirrored
- Tile video track resolves to Track.Source.Camera publications of the
LocalParticipant / each RemoteParticipant
- Room listens for TrackMuted / TrackUnmuted and re-publishes remote
state so peers switch to avatar placeholder when a camera is disabled
Deafen:
- New isDeafened state + toggleDeafen action. Sets `muted = true` on all
attached `<audio[data-livekit-track]>` plus mutes fresh ones on attach
via module-level flag
- Broadcast state over the LiveKit data channel
({type:'presence', deafened}) so peers can render the headphones-off
badge. Attributes API not used because the self-hosted server may run
older LiveKit versions
- remoteDeafen: Record<identity, bool> exposed via context, bumped on
DataReceived and re-broadcast on ParticipantConnected
Incoming video call:
- acceptIncoming takes an optional CallKind override so the receiver can
answer a video invite with audio only or promote an audio invite to
video on accept
- IncomingCallPanel shows two accept buttons (audio + video) when the
invite is a video call
Audio devices:
- audioSettings adds inputDeviceId + outputDeviceId, persisted
- CallContext uses them on setMicrophoneEnabled, plus new
setAudioInputDevice / setAudioOutputDevice hot-swap actions.
Output swap applies setSinkId to every attached remote-audio element
since LiveKit's own switchActiveDevice only tracks elements it
attached itself
- SettingsPage "Mikrofon" + "Ausgabegerät" selects with devicechange
listener and a permission-probe button
Fullscreen mode:
- Replaced absolute-positioned speaker + floating thumbnails with a real
flex layout. Default = even grid of all tiles. Clicking a tile flips
to big-speaker + horizontal thumbnail strip. Click focused tile =
back to grid
- Controls overlay pinned bottom; content wrapper has pb-24 so tiles
never sit behind the toolbar
- Grid now uses explicit grid-rows-* so cells get a defined 1fr height
(without it, video intrinsic dimensions blew tiles past the container
bounds on Windows)
UI chips:
- Mic-off badge combines isMuted flag AND
localParticipant.isMicrophoneEnabled, so a user with no mic / denied
permission sees the badge + the toolbar button red even though they
never pressed mute
- Deafen badge on tile chips for local + remote (remote driven by the
data-channel broadcast)
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
Backup / restore flow:
- deviceBackup.ts: v2 format wrapping userId + deviceId + privateKey in
an encrypted JSON payload so restore can re-seed localStorage, vault,
and reattach to the existing server-side device row without provisioning
a new one (conv-key bundles stay valid, no "awaiting key" state)
- shared/auth: restoreDeviceFromServerRecord — verifies session.user.id
matches the backup's userId, confirms the server device row still
exists, then writes the private key into the local secret store
- BackupExportDialog — passphrase + confirm, generates portable string,
copy + download .txt
- DeviceRestore — textarea + passphrase → seeds vault + writes
deviceId cache, treats this install as the original device
- DevicePage now has tabs "Neu einrichten" / "Backup wiederherstellen"
- BackupPromptBanner — post-registration nudge, reads sessionStorage
signal from fresh provisions and persists "never-ask-again" in
localStorage so it stops nagging
- SettingsPage backup section: uses the new dialog; removes the
dangerous in-place key import (restore now lives in the device flow)
Username casing:
- Migration 20260420000002 drops lower() from the handle_new_user trigger
and widens the regex to [A-Za-z0-9_]. profiles.username is citext so
uniqueness + lookups stay case-insensitive regardless of stored casing
- Shared auth: trim() only, no toLowerCase on signup/lookups/search.
ilike handles CI anyway and citext makes client normalisation redundant
- AuthPage regex + input preserve case, FriendsPage search preserves case
- i18n (de+en): updated username_hint / username_invalid / ERR_USERNAME_INVALID
to reflect the new rule
Quick wins:
- React Router v7 future flags (v7_startTransition + v7_relativeSplatPath)
set on BrowserRouter — silences the upgrade warning
- appUpdates.checkForUpdate: swallow benign network/fetch/"could not
fetch valid release JSON" cases silently instead of console spam
- osNotify: persist an "asked" marker in localStorage so the permission
prompt only fires once per install (OS already persists the answer,
but the plugin re-queries loudly otherwise)
Messages:
- Reply-to: hover action, composer chip with cancel, quote bubble inside
the replying message with tap-to-jump + amber highlight ring
- Search: header search button toggles in-conversation search bar with
prev/next + match counter, auto-jump to active match
- Forward: multi-select conversation picker. Attachments are now carried
over: download + decrypt source, re-encrypt under each target conv-key,
re-upload with fresh per-attachment keys, insert new attachment rows
Conversations:
- Archive + mute per member. New migration 20260420000001 adds `archived`
+ `muted_until` on conversation_members. Shared helpers:
setConversationArchived / setConversationMutedUntil / isConversationMuted
- ChatsPage: archive toggle in header with unread badge for archived
bucket, split active/archived lists, muted indicator (BellOff icon,
dimmed unread badge)
- ConversationRowMenu via createPortal (escapes sidebar overflow clip),
forwardRef-based MenuItem so submenu positioning refs survive React 18
- ConversationsContext: suppresses notification sound + OS notif when
target conversation is muted
- Refresh on `profiles UPDATE` realtime so peer avatar / displayName
changes flow to conversation.members without manual refresh
Resilience:
- ErrorBoundary (Discord-style): centred spinner + escalating copy, no
manual reload button. Backoff retry schedule [2s, 4s, 8s, 15s, 30s].
Uses a keyed Fragment (not a div wrapper) so flex h-full chains survive
- App wrapped root + per-route RouteBoundary, conversation-level boundary
- AuthContext: flip `ready` immediately on cached session read; validate
getUser in background so a stalled/offline Supabase doesn't freeze the
app on the loading spinner
Crypto:
- Swap libsodium-wrappers -> libsodium-wrappers-sumo (compact build was
missing crypto_pwhash so Argon2id vault KDF threw, falling back to
plaintext localStorage on every launch)
- Shim d.ts for sumo types (sumo is API superset, no official types ship)
- vite optimizeDeps includes sumo with the "require" condition
- secureFileStore: exists(dir) check before mkdir; surface genuine
permission errors instead of silent catch
Tauri:
- fs scope adds `$APPLOCALDATA` direct entry (not just /**) so the app
data directory itself can be mkdir'd on first launch
Chat layout:
- Skip call_event messages when computing avatar run boundaries so a
regular bubble followed by a call event from the same sender still
shows its avatar
Visual:
- Light/dark theme via ThemeContext + CSS vars
- New design tokens (surface/fg/accent/line/etc.) across all pages
- Reusable Avatar component (img + letter fallback) wired into UserBar,
ConversationHeader, ChatsPage, FriendsPage, GroupInfoPanel, IncomingCallPanel
Calls:
- Split call UI: IncomingCallPanel, InCallPanel, CallControls,
CallParticipantTile, ScreenShareViewer
- Active speaker hook (useActiveSpeakers)
- Fix: ActiveCallBanner stayed hidden after hangup while peers in room.
- useCallPresence: bind presence callbacks only when we own subscribe
(Supabase forbids .on() after .subscribe() on shared dedup'd channels)
- useCallPresence: never removeChannel — channel is shared with CallContext
so tearing it down on ConversationHeader unmount killed live tracking
- ActiveCallBanner: lastCallConversationId fallback so banner shows
instantly after hangup, auto-dismiss when room confirmed empty
- Drop unused useAnyActiveCall
Bump tauri version 0.5.0 -> 0.6.0