Compare commits

...

8 Commits

5 changed files with 131 additions and 26 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chat-app/desktop",
"version": "0.21.0",
"version": "0.21.3",
"private": true,
"description": "Electron desktop client (Windows / macOS / Linux)",
"type": "module",
Binary file not shown.
@@ -5,12 +5,12 @@ import {
decryptMessages,
encryptAndUploadAttachment,
fetchConversationMessages,
getOrCreateConvKey,
insertAttachmentRow,
MAX_ATTACHMENT_BYTES,
type MessageWithCipher,
sendEncryptedMessage,
shareConvKeyToUser,
tryGetConvKey,
} from '@chat-app/shared/chat';
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -154,8 +154,24 @@ 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;
// Use `getOrCreateConvKey` rather than `tryGetConvKey` so that if we
// can't unwrap our bundle at the active version (we lost the device
// key, or the bundle was wiped by the 0.18.0 reset_user_key bug, or
// we only ever had a legacy `recipient_device_id` row), the helper
// auto-rotates the conv-key to version+1 and wraps fresh bundles
// for every member with a `user_keys` row. This is the only path
// that recovers stuck-legacy conversations on the receive side —
// `tryGetConvKey` just returned null and left the chat permanently
// un-decryptable for the locked-out party.
const handle = await getOrCreateConvKey(supabase, conversationId, {
userId,
privateKey: priv,
});
if (cancelled) return;
// If the helper rotated, all current members with a `user_keys`
// public key were already wrapped by `rotateConvKey`. No further
// sweep work is needed.
if (handle.keyVersion > version) return;
const { data: members, error: mErr } = await supabase
.from('conversation_members')
+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 (
+56 -8
View File
@@ -110,7 +110,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 +144,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 +170,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 +281,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;