Compare commits

...

10 Commits

Author SHA1 Message Date
byGalax 588b843904 chore(desktop): release v0.21.4 2026-05-21 23:12:38 +02:00
byGalax dbf8030e93 fix(conv-key): rotate-instead-of-share + cache invalidation; fire-first realtime inserts (no sound-vs-text gap)
Friend-DM "Nachricht nicht lesbar" recurred even after v0.21.3 because the
proactive sweep called shareConvKeyToUser, which reads the module-level
conv-key cache first. After a server-side cleanup the cache still held the
stale locally-bootstrapped key, so each side wrapped its own different key
for the peer and the bundles diverged anew.

Switch the sweep to rotate_conv_key when any peer's user-id bundle is
missing at the active version: a fresh symmetric key is generated, wrapped
for every member at their CURRENT pubkey, and the active version is bumped
under a row-level FOR UPDATE lock. Concurrent rotations are race-safe — the
loser sees "new version must be greater" and bails; the winner's bundles
propagate via realtime.

Realtime conversation_keys subscription now invalidates the cache for the
affected (conversationId, key_version) on any INSERT/UPDATE/DELETE — so
admin cleanups, peer rotations, or device wraps can no longer leave a
stale entry in this client's session cache.

queueInsert now fires the first event of a quiet period immediately and
only collapses follow-up bursts. BATCH_WINDOW_MS dropped 250 → 80 ms.
This closes the ~250 ms gap between the notification sound and the
message body appearing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:10:36 +02:00
byGalax 615770722e chore(desktop): release v0.21.3 2026-05-21 22:52:50 +02:00
byGalax f1cba99b9e fix(conv-key): bootstrap re-fetches canonical key after share to handle concurrent race 2026-05-21 22:51:17 +02:00
byGalax f60c5c676a chore(desktop): release v0.21.2 2026-05-18 15:40:56 +02:00
byGalax 8e6be3256d fix(conv-key): rotate on unwrap failure (post-reset_user_key recovery) 2026-05-18 15:38:31 +02:00
byGalax 508c53b451 chore(desktop): release v0.21.1 2026-05-18 14:40:29 +02:00
byGalax e2f86bc377 fix(chat): snap to bottom after send to mask composer-shrink layout shift 2026-05-18 14:29:31 +02:00
byGalax 92a6e01a26 fix(chat): instant scroll + larger at-bottom threshold + footer spacer (Discord-clean) 2026-05-18 14:21:04 +02:00
byGalax 3d959aaadf fix(chat): auto-rotate stuck conv-keys on chat open (receive-side recovery) 2026-05-18 14:03:35 +02:00
5 changed files with 263 additions and 80 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chat-app/desktop",
"version": "0.21.0",
"version": "0.21.4",
"private": true,
"description": "Electron desktop client (Windows / macOS / Linux)",
"type": "module",
Binary file not shown.
+121 -48
View File
@@ -1,16 +1,16 @@
import { fetchPeerPublicKeys } from '@chat-app/shared/auth';
import {
type AttachmentHandle,
clearConvKeyCache,
type DecryptedMessage,
decryptMessages,
encryptAndUploadAttachment,
fetchConversationMessages,
getOrCreateConvKey,
insertAttachmentRow,
MAX_ATTACHMENT_BYTES,
type MessageWithCipher,
rotateConvKey,
sendEncryptedMessage,
shareConvKeyToUser,
tryGetConvKey,
} from '@chat-app/shared/chat';
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -125,12 +125,25 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
});
}, [userId]);
// Proactive rewrap sweep: when a conversation opens, walk every accepted
// member and ensure the active conv-key has a `recipient_user_id` bundle
// for them. Members who are missing one (typically peers who haven't yet
// migrated to the per-user key model) get a best-effort wrap from the
// local conv-key handle. Closes the legacy migration gap so peer B can
// read on first unlock without manual intervention from A.
// Proactive rewrap sweep: when a conversation opens, ensure the active
// conv-key has a `recipient_user_id` bundle for every accepted member.
//
// If any peer is missing a bundle at the active version, the previous
// implementation called `shareConvKeyToUser` for each missing peer
// that helper reads from the module-level conv-key cache first, and if
// the cache held a STALE locally-generated key (from a buggy bootstrap
// race in an earlier app version), the stale key got propagated to the
// peer's row. Both sides then encrypt with mutually un-mergeable keys
// and every message is "Nachricht nicht lesbar" forever (incident:
// conv aae12d84).
//
// The replacement: when any peer is missing, call `rotateConvKey` once.
// Rotation generates a fresh symmetric key locally, fetches each member's
// CURRENT pubkey, wraps the fresh key for everyone, and atomically bumps
// `active_key_version` via the `rotate_conv_key` RPC (FOR UPDATE lock
// serialises concurrent rotations). This bypasses the cache entirely:
// the new version's cache entry is the just-rotated key, and the stale
// entry at the old version is irrelevant because nobody reads it any more.
useEffect(() => {
if (!conversationId || !userId) return;
let cancelled = false;
@@ -154,31 +167,41 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
// db-types snapshot predates the active_key_version column; cast via unknown.
const version = (convRow as unknown as { active_key_version: number }).active_key_version;
const handle = await tryGetConvKey(supabase, conversationId, userId, priv, version);
if (!handle || cancelled) return;
// First, make sure we have a usable handle for the active version
// (this auto-rotates if we're locked out of our own bundle — the
// recovery path added in v0.21.1/v0.21.2).
const handle = await getOrCreateConvKey(supabase, conversationId, {
userId,
privateKey: priv,
});
if (cancelled) return;
if (handle.keyVersion > version) return; // already rotated by helper
// Check membership state on the server.
const { data: members, error: mErr } = await supabase
.from('conversation_members')
.select('user_id, accepted')
.eq('conversation_id', conversationId);
if (mErr || !members) return;
const memberIds = (members as Array<{ user_id: string; accepted: boolean }>)
const peerIds = (members as Array<{ user_id: string; accepted: boolean }>)
.filter((m) => m.accepted && m.user_id !== userId)
.map((m) => m.user_id);
if (memberIds.length === 0) return;
if (peerIds.length === 0) return;
const peers = await fetchPeerPublicKeys(supabase, memberIds);
for (const peer of peers) {
if (cancelled) return;
const { count, error: cntErr } = await (
// Count how many of the peers have a recipient_user_id bundle at
// the active version. If any are missing, rotate to V+1 — the
// rotation will wrap a fresh key for every accepted member with a
// user_keys row.
const { data: existingRows, error: rowsErr } = await (
supabase as unknown as {
from: (t: string) => {
select: (s: string, o?: object) => {
eq: (...a: unknown[]) => {
eq: (...a: unknown[]) => {
eq: (
...a: unknown[]
) => Promise<{ count: number | null; error: unknown }>;
select: (s: string) => {
eq: (c: string, v: string) => {
eq: (c: string, v: number) => {
in: (c: string, v: string[]) => Promise<{
data: Array<{ recipient_user_id: string }> | null;
error: unknown;
}>;
};
};
};
@@ -186,24 +209,34 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
}
)
.from('conversation_keys')
.select('recipient_user_id', { count: 'exact', head: true })
.select('recipient_user_id')
.eq('conversation_id', conversationId)
.eq('recipient_user_id', peer.userId)
.eq('key_version', version);
if (cntErr) continue;
if ((count ?? 0) === 0) {
try {
await shareConvKeyToUser(
supabase,
conversationId,
peer.userId,
peer.publicKey,
{ userId, privateKey: priv },
.eq('key_version', version)
.in('recipient_user_id', peerIds);
if (rowsErr) return;
const wrappedPeerIds = new Set(
(existingRows ?? []).map((r) => r.recipient_user_id),
);
const missing = peerIds.filter((id) => !wrappedPeerIds.has(id));
if (missing.length === 0) return;
// At least one peer is missing a bundle — rotate. We deliberately do
// NOT use the cached conv-key here. The rotation generates a fresh
// key wrapped to every current member's CURRENT pubkey, so any
// staleness in the local cache for the OLD version is irrelevant
// going forward.
try {
await rotateConvKey(supabase, conversationId, {
userId,
privateKey: priv,
});
} catch (err) {
console.warn('proactive rewrap failed for', peer.userId, err);
}
}
// Most likely cause: a concurrent peer also called rotate and
// won the race; their bumped active_key_version makes our
// `p_new_version <= cur_version` and the RPC raises. That's fine —
// the next chat-open / send will fetch the new active version and
// unwrap the bundle that peer wrapped for us.
console.warn('proactive rotate failed (likely concurrent rotation)', err);
}
} catch (err) {
console.warn('proactive rewrap sweep failed', err);
@@ -474,11 +507,14 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
void refresh();
// Batch INSERT bursts so a paste / backfill doesn't fire N parallel
// refetches + decrypts. If more than BATCH_BURST_THRESHOLD ids arrive
// within BATCH_WINDOW_MS, collapse to a single refresh() which pulls
// the last 100 in one query — cheaper and keeps order stable. For
// lone inserts the per-id path stays so latency is unchanged.
const BATCH_WINDOW_MS = 250;
// refetches + decrypts. The first event in a quiet period fires
// `handleInsert` immediately so single incoming messages don't sit
// behind a debounce timer (previous behaviour: 250 ms blank between
// notification-sound and message body). Subsequent events arriving
// within BATCH_WINDOW_MS of the first are buffered; if the burst grows
// past BATCH_BURST_THRESHOLD the buffered tail collapses into one
// `refresh()` instead of N individual refetches.
const BATCH_WINDOW_MS = 80;
const BATCH_BURST_THRESHOLD = 3;
let burstBuffer: Array<Record<string, unknown>> = [];
let burstTimer: number | null = null;
@@ -497,6 +533,15 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
}
};
const queueInsert = (row: Record<string, unknown>) => {
if (burstBuffer.length === 0 && burstTimer === null) {
// First event in a quiet period — fire immediately so the user sees
// the message right when they hear the notification sound. Arm a
// short window in case a burst follows; follow-ups go through the
// buffer and may collapse into a refresh.
void handleInsert(row);
burstTimer = window.setTimeout(flushBurst, BATCH_WINDOW_MS);
return;
}
burstBuffer.push(row);
if (burstTimer === null) {
burstTimer = window.setTimeout(flushBurst, BATCH_WINDOW_MS);
@@ -523,18 +568,46 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
}
},
)
// When a peer device wraps the conversation-key for us (e.g. we just
// registered a fresh device), re-decrypt the visible messages.
// Any conversation_keys change for this conv invalidates the cached
// conv-key for the affected version. The module-level cache in
// shared/chat/convKeys.ts otherwise holds the previously-unwrapped key
// forever within a session — which is exactly what propagated the
// stale local bootstrap key in conv aae12d84, recreating divergent
// bundles after a server-side cleanup. Clearing on any INSERT/UPDATE/
// DELETE for the conv forces the next `getOrCreateConvKey` /
// `tryGetConvKey` call to re-fetch the canonical bundle from the
// server. Cheap (a single Map.delete), defensive, and avoids stale-
// cache propagation across all of {peer rotation, device wrap, admin
// cleanup}.
//
// We also keep the historical "device wrap → refresh" trigger so a
// freshly-registered device of our own re-decrypts in place.
.on(
'postgres_changes',
{
event: 'INSERT',
event: '*',
schema: 'public',
table: 'conversation_keys',
filter: 'conversation_id=eq.' + conversationId,
},
(payload: { new: { recipient_device_id?: string } }) => {
if (payload.new?.recipient_device_id === deviceId) {
(payload: {
eventType: 'INSERT' | 'UPDATE' | 'DELETE';
new: { recipient_device_id?: string; key_version?: number };
old: { recipient_device_id?: string; key_version?: number };
}) => {
const v =
payload.eventType === 'DELETE'
? payload.old?.key_version
: payload.new?.key_version;
if (typeof v === 'number') {
clearConvKeyCache(conversationId, v);
} else {
clearConvKeyCache(conversationId);
}
if (
payload.eventType === 'INSERT' &&
payload.new?.recipient_device_id === deviceId
) {
void refresh();
}
},
+47 -6
View File
@@ -754,6 +754,24 @@ export function ConversationPage() {
lastPendingCountRef.current = pending.length;
}, [pending.length]);
// Snap the viewport back to the bottom after a send. The composer
// shrinks (cleared text, dismissed reply preview, dropped attachment
// thumbs) which lets the Virtuoso area grow vertically — leaving the
// just-sent bubble visibly above the new bottom for a frame.
// `requestAnimationFrame` defers the scroll until React has committed
// the composer-height change, so Virtuoso's ResizeObserver has
// already seen the new viewport and `index: 'LAST', align: 'end'`
// targets the correct bottom edge.
const snapToBottom = useCallback(() => {
window.requestAnimationFrame(() => {
virtuosoRef.current?.scrollToIndex({
index: 'LAST',
align: 'end',
behavior: 'auto',
});
});
}, []);
async function handleSend(e?: React.FormEvent) {
e?.preventDefault();
if ((!text.trim() && attachments.length === 0) || sending) return;
@@ -773,6 +791,7 @@ export function ConversationPage() {
setStickToBottom(true);
notifyStopTyping();
if (id) clearDraft(id);
snapToBottom();
} catch (err: unknown) {
const code = extractErrorCode(err);
setSendError(
@@ -798,13 +817,14 @@ export function ConversationPage() {
setReplyTo(null);
setStickToBottom(true);
notifyStopTyping();
snapToBottom();
} catch (err: unknown) {
setPollError(err instanceof Error ? err.message : 'Umfrage konnte nicht gesendet werden');
} finally {
setPollSending(false);
}
},
[send, replyTo?.id, notifyStopTyping],
[send, replyTo?.id, notifyStopTyping, snapToBottom],
);
const handleCreateWhiteboard = useCallback(async () => {
@@ -817,12 +837,13 @@ export function ConversationPage() {
setReplyTo(null);
setStickToBottom(true);
setOpenWhiteboardId(board.id);
snapToBottom();
} catch (err: unknown) {
setSendError(err instanceof Error ? err.message : 'Whiteboard konnte nicht angelegt werden');
} finally {
setCreatingWhiteboard(false);
}
}, [id, creatingWhiteboard, send, replyTo?.id]);
}, [id, creatingWhiteboard, send, replyTo?.id, snapToBottom]);
const handleStartWatchTogether = useCallback(async () => {
if (!id) return;
@@ -842,12 +863,13 @@ export function ConversationPage() {
setWatchDialogOpen(false);
setWatchUrl('');
setOpenWatchSessionId(ws.id);
snapToBottom();
} catch (err: unknown) {
setWatchError(err instanceof Error ? err.message : 'Konnte Watch-Together nicht starten');
} finally {
setWatchCreating(false);
}
}, [id, watchUrl, send, replyTo?.id]);
}, [id, watchUrl, send, replyTo?.id, snapToBottom]);
const handleStartGame = useCallback(async (gameType: GameType) => {
if (!id) return;
@@ -874,12 +896,13 @@ export function ConversationPage() {
setStickToBottom(true);
setGameDialogOpen(false);
setOpenGameId(game.id);
snapToBottom();
} catch (err: unknown) {
setGameError(err instanceof Error ? err.message : 'Konnte Spiel nicht starten');
} finally {
setGameCreating(false);
}
}, [id, conversation, myId, send, replyTo?.id]);
}, [id, conversation, myId, send, replyTo?.id, snapToBottom]);
async function ingestFiles(files: File[]) {
const compressed = await compressImages(files);
@@ -1046,14 +1069,32 @@ export function ConversationPage() {
// the bottom; returning `false` from the callback when they're
// scrolled up preserves their reading position when realtime
// messages arrive (critical UX: do NOT jerk the user).
followOutput={(isAtBottom) => (isAtBottom ? 'smooth' : false)}
//
// We deliberately use 'auto' (instant) rather than 'smooth':
// with a smooth scroll animation, atBottomStateChange fires
// `false` mid-animation (scrollTop is briefly above the new
// bottom) and then `true` after settle — that flips
// stickToBottom twice, flashing the "Zum neuesten" pill and
// re-rendering the whole list. Instant scroll has zero
// mid-animation state so the cascade never happens.
followOutput={(isAtBottom) => (isAtBottom ? 'auto' : false)}
atBottomStateChange={handleAtBottomStateChange}
atBottomThreshold={80}
// 250 px tolerance — large enough that appending a tall row
// (image, voice note, grouped attachments) doesn't push the
// user out of the at-bottom zone. The previous 80 px flipped
// stickToBottom on nearly every typical message arrival.
atBottomThreshold={250}
rangeChanged={handleRangeChanged}
startReached={handleStartReached}
// Render rows just outside the viewport so fast scrolling
// doesn't briefly flash empty space.
increaseViewportBy={400}
// Visual breathing space below the last message so a bubble
// bottom doesn't sit flush against the composer top — matches
// Discord's chat-pane bottom padding.
components={{
Footer: () => <div style={{ height: '12px' }} />,
}}
itemContent={(_index, row) => {
if (row.kind === 'loader') {
return (
+78 -9
View File
@@ -28,7 +28,28 @@ export interface ConvKeyHandle {
const cache = new Map<string, ConvKeyHandle>();
const cacheKey = (convId: string, v: number) => convId + '@' + v;
export function clearConvKeyCache(): void { cache.clear(); }
// Clear the in-memory conv-key cache. Three modes:
// * no args → clear everything (e.g. on logout)
// * convId only → clear all key-version entries for this conversation
// * convId + v → clear just the specific (conv, version) entry
//
// Callers that observe a peer rotation or a server-side conv-keys mutation
// MUST invalidate the affected entries so subsequent `getOrCreateConvKey` /
// `tryGetConvKey` calls re-fetch the canonical bundle from the server
// instead of returning a now-stale cached key.
export function clearConvKeyCache(conversationId?: string, keyVersion?: number): void {
if (conversationId === undefined) {
cache.clear();
return;
}
if (keyVersion !== undefined) {
cache.delete(cacheKey(conversationId, keyVersion));
return;
}
for (const key of Array.from(cache.keys())) {
if (key.startsWith(conversationId + '@')) cache.delete(key);
}
}
async function listMemberPublicKeys(
client: AppSupabaseClient,
@@ -110,7 +131,26 @@ export async function bootstrapConvKey(
p_bundles: bundles,
});
if (error) throw error;
const handle = { conversationId, keyVersion, key: convKey };
// `share_conv_keys` uses `ON CONFLICT (conv, recipient_user_id, key_version)
// DO NOTHING`. If a concurrent peer bootstrapped first at the same version,
// OUR INSERTs were silently skipped server-side and the row on the server
// holds THEIR conv-key, not ours. Trusting the locally-generated key here
// would leave both clients with mutually un-decryptable bundles (each
// encrypting/decrypting with its own key — exactly the bug that broke
// conv aae12d84). Re-fetch our own bundle and unwrap to get the CANONICAL
// server key. Whoever wrote first wins; the loser converges.
const ownBundle = await fetchKeyBundle(client, conversationId, own.userId, keyVersion);
if (!ownBundle) {
throw new Error('bootstrapConvKey: own bundle missing after share_conv_keys');
}
const canonicalKey = await unwrapConvKey(
ownBundle.encryptedKey,
ownBundle.nonce,
ownBundle.sender.senderPublicKey,
own.privateKey,
);
const handle = { conversationId, keyVersion, key: canonicalKey };
cache.set(cacheKey(conversationId, keyVersion), handle);
return handle;
}
@@ -125,12 +165,25 @@ export async function getOrCreateConvKey(
if (cached) return cached;
const bundle = await fetchKeyBundle(client, conversationId, own.userId, version);
if (bundle) {
try {
const key = await unwrapConvKey(
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, own.privateKey,
);
const handle = { conversationId, keyVersion: version, key };
cache.set(cacheKey(conversationId, version), handle);
return handle;
} catch (err) {
// A bundle exists for us but our current private key cannot unwrap it.
// The most common cause is `reset_user_key`: a fresh user-key pair was
// generated locally while the on-server bundle is still wrapped against
// the previous public key. Treat this the same as "no bundle for me" —
// mint a fresh conv-key at version+1 wrapped to our CURRENT key. Old
// messages stay unreadable for us; new ones flow.
console.warn(
'[conv-key] unwrap own bundle failed at v' + version + ' — auto-rotating',
err,
);
}
}
const { count, error: cntErr } = await rawFrom(client, 'conversation_keys')
.select('recipient_user_id', { count: 'exact', head: true })
@@ -138,12 +191,13 @@ export async function getOrCreateConvKey(
.eq('key_version', version);
if (cntErr) throw cntErr;
if ((count ?? 0) > 0) {
// Rows exist for this version, but none for me. Either I lost the device-key
// that originally received my bundle, or my own bundle was wiped by the
// 0.18.0 reset_user_key bug. Either way, the only way out is to mint a fresh
// conv-key at version+1 and wrap it for everyone we can. Old messages stay
// unreadable for me; new ones flow.
console.info('[conv-key] no bundle for me at v' + version + ' — auto-rotating');
// Rows exist for this version, but none usable for me. Either I lost the
// device-key that originally received my bundle, my own bundle was wiped
// by the 0.18.0 reset_user_key bug, or my key was reset and the existing
// bundle is unwrappable (handled in the try/catch above). The only way
// out is to mint a fresh conv-key at version+1 and wrap it for everyone
// we can. Old messages stay unreadable for me; new ones flow.
console.info('[conv-key] no usable bundle for me at v' + version + ' — auto-rotating');
return rotateConvKey(client, conversationId, own);
}
return bootstrapConvKey(client, conversationId, own, version);
@@ -248,9 +302,24 @@ export async function tryGetConvKey(
if (cached) return cached;
const bundle = await fetchKeyBundle(client, conversationId, ownUserId, keyVersion);
if (!bundle) return null;
const key = await unwrapConvKey(
let key: Uint8Array;
try {
key = await unwrapConvKey(
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, ownPrivateKey,
);
} catch (err) {
// Bundle exists but the current private key doesn't unwrap it (typically
// after `reset_user_key`). Return null so the caller treats the message
// as un-decryptable instead of throwing and killing the whole batch.
// The conversation will be auto-rotated to a fresh key on the next send
// or chat open via `getOrCreateConvKey`'s own recovery path.
console.warn(
'[conv-key] tryGetConvKey unwrap failed at v' + keyVersion +
' (conv=' + conversationId.slice(0, 8) + ') — marking as un-decryptable',
err,
);
return null;
}
const handle = { conversationId, keyVersion, key };
cache.set(cacheKey(conversationId, keyVersion), handle);
return handle;