Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f60c5c676a | |||
| 8e6be3256d | |||
| 508c53b451 | |||
| e2f86bc377 | |||
| 92a6e01a26 | |||
| 3d959aaadf |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@chat-app/desktop",
|
"name": "@chat-app/desktop",
|
||||||
"version": "0.21.0",
|
"version": "0.21.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
Binary file not shown.
@@ -5,12 +5,12 @@ import {
|
|||||||
decryptMessages,
|
decryptMessages,
|
||||||
encryptAndUploadAttachment,
|
encryptAndUploadAttachment,
|
||||||
fetchConversationMessages,
|
fetchConversationMessages,
|
||||||
|
getOrCreateConvKey,
|
||||||
insertAttachmentRow,
|
insertAttachmentRow,
|
||||||
MAX_ATTACHMENT_BYTES,
|
MAX_ATTACHMENT_BYTES,
|
||||||
type MessageWithCipher,
|
type MessageWithCipher,
|
||||||
sendEncryptedMessage,
|
sendEncryptedMessage,
|
||||||
shareConvKeyToUser,
|
shareConvKeyToUser,
|
||||||
tryGetConvKey,
|
|
||||||
} from '@chat-app/shared/chat';
|
} from '@chat-app/shared/chat';
|
||||||
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
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.
|
// 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 version = (convRow as unknown as { active_key_version: number }).active_key_version;
|
||||||
|
|
||||||
const handle = await tryGetConvKey(supabase, conversationId, userId, priv, version);
|
// Use `getOrCreateConvKey` rather than `tryGetConvKey` so that if we
|
||||||
if (!handle || cancelled) return;
|
// 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
|
const { data: members, error: mErr } = await supabase
|
||||||
.from('conversation_members')
|
.from('conversation_members')
|
||||||
|
|||||||
@@ -754,6 +754,24 @@ export function ConversationPage() {
|
|||||||
lastPendingCountRef.current = pending.length;
|
lastPendingCountRef.current = pending.length;
|
||||||
}, [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) {
|
async function handleSend(e?: React.FormEvent) {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
if ((!text.trim() && attachments.length === 0) || sending) return;
|
if ((!text.trim() && attachments.length === 0) || sending) return;
|
||||||
@@ -773,6 +791,7 @@ export function ConversationPage() {
|
|||||||
setStickToBottom(true);
|
setStickToBottom(true);
|
||||||
notifyStopTyping();
|
notifyStopTyping();
|
||||||
if (id) clearDraft(id);
|
if (id) clearDraft(id);
|
||||||
|
snapToBottom();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const code = extractErrorCode(err);
|
const code = extractErrorCode(err);
|
||||||
setSendError(
|
setSendError(
|
||||||
@@ -798,13 +817,14 @@ export function ConversationPage() {
|
|||||||
setReplyTo(null);
|
setReplyTo(null);
|
||||||
setStickToBottom(true);
|
setStickToBottom(true);
|
||||||
notifyStopTyping();
|
notifyStopTyping();
|
||||||
|
snapToBottom();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setPollError(err instanceof Error ? err.message : 'Umfrage konnte nicht gesendet werden');
|
setPollError(err instanceof Error ? err.message : 'Umfrage konnte nicht gesendet werden');
|
||||||
} finally {
|
} finally {
|
||||||
setPollSending(false);
|
setPollSending(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[send, replyTo?.id, notifyStopTyping],
|
[send, replyTo?.id, notifyStopTyping, snapToBottom],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreateWhiteboard = useCallback(async () => {
|
const handleCreateWhiteboard = useCallback(async () => {
|
||||||
@@ -817,12 +837,13 @@ export function ConversationPage() {
|
|||||||
setReplyTo(null);
|
setReplyTo(null);
|
||||||
setStickToBottom(true);
|
setStickToBottom(true);
|
||||||
setOpenWhiteboardId(board.id);
|
setOpenWhiteboardId(board.id);
|
||||||
|
snapToBottom();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setSendError(err instanceof Error ? err.message : 'Whiteboard konnte nicht angelegt werden');
|
setSendError(err instanceof Error ? err.message : 'Whiteboard konnte nicht angelegt werden');
|
||||||
} finally {
|
} finally {
|
||||||
setCreatingWhiteboard(false);
|
setCreatingWhiteboard(false);
|
||||||
}
|
}
|
||||||
}, [id, creatingWhiteboard, send, replyTo?.id]);
|
}, [id, creatingWhiteboard, send, replyTo?.id, snapToBottom]);
|
||||||
|
|
||||||
const handleStartWatchTogether = useCallback(async () => {
|
const handleStartWatchTogether = useCallback(async () => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -842,12 +863,13 @@ export function ConversationPage() {
|
|||||||
setWatchDialogOpen(false);
|
setWatchDialogOpen(false);
|
||||||
setWatchUrl('');
|
setWatchUrl('');
|
||||||
setOpenWatchSessionId(ws.id);
|
setOpenWatchSessionId(ws.id);
|
||||||
|
snapToBottom();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setWatchError(err instanceof Error ? err.message : 'Konnte Watch-Together nicht starten');
|
setWatchError(err instanceof Error ? err.message : 'Konnte Watch-Together nicht starten');
|
||||||
} finally {
|
} finally {
|
||||||
setWatchCreating(false);
|
setWatchCreating(false);
|
||||||
}
|
}
|
||||||
}, [id, watchUrl, send, replyTo?.id]);
|
}, [id, watchUrl, send, replyTo?.id, snapToBottom]);
|
||||||
|
|
||||||
const handleStartGame = useCallback(async (gameType: GameType) => {
|
const handleStartGame = useCallback(async (gameType: GameType) => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -874,12 +896,13 @@ export function ConversationPage() {
|
|||||||
setStickToBottom(true);
|
setStickToBottom(true);
|
||||||
setGameDialogOpen(false);
|
setGameDialogOpen(false);
|
||||||
setOpenGameId(game.id);
|
setOpenGameId(game.id);
|
||||||
|
snapToBottom();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setGameError(err instanceof Error ? err.message : 'Konnte Spiel nicht starten');
|
setGameError(err instanceof Error ? err.message : 'Konnte Spiel nicht starten');
|
||||||
} finally {
|
} finally {
|
||||||
setGameCreating(false);
|
setGameCreating(false);
|
||||||
}
|
}
|
||||||
}, [id, conversation, myId, send, replyTo?.id]);
|
}, [id, conversation, myId, send, replyTo?.id, snapToBottom]);
|
||||||
|
|
||||||
async function ingestFiles(files: File[]) {
|
async function ingestFiles(files: File[]) {
|
||||||
const compressed = await compressImages(files);
|
const compressed = await compressImages(files);
|
||||||
@@ -1046,14 +1069,32 @@ export function ConversationPage() {
|
|||||||
// the bottom; returning `false` from the callback when they're
|
// the bottom; returning `false` from the callback when they're
|
||||||
// scrolled up preserves their reading position when realtime
|
// scrolled up preserves their reading position when realtime
|
||||||
// messages arrive (critical UX: do NOT jerk the user).
|
// 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}
|
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}
|
rangeChanged={handleRangeChanged}
|
||||||
startReached={handleStartReached}
|
startReached={handleStartReached}
|
||||||
// Render rows just outside the viewport so fast scrolling
|
// Render rows just outside the viewport so fast scrolling
|
||||||
// doesn't briefly flash empty space.
|
// doesn't briefly flash empty space.
|
||||||
increaseViewportBy={400}
|
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) => {
|
itemContent={(_index, row) => {
|
||||||
if (row.kind === 'loader') {
|
if (row.kind === 'loader') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -125,12 +125,25 @@ export async function getOrCreateConvKey(
|
|||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const bundle = await fetchKeyBundle(client, conversationId, own.userId, version);
|
const bundle = await fetchKeyBundle(client, conversationId, own.userId, version);
|
||||||
if (bundle) {
|
if (bundle) {
|
||||||
const key = await unwrapConvKey(
|
try {
|
||||||
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, own.privateKey,
|
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);
|
const handle = { conversationId, keyVersion: version, key };
|
||||||
return handle;
|
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')
|
const { count, error: cntErr } = await rawFrom(client, 'conversation_keys')
|
||||||
.select('recipient_user_id', { count: 'exact', head: true })
|
.select('recipient_user_id', { count: 'exact', head: true })
|
||||||
@@ -138,12 +151,13 @@ export async function getOrCreateConvKey(
|
|||||||
.eq('key_version', version);
|
.eq('key_version', version);
|
||||||
if (cntErr) throw cntErr;
|
if (cntErr) throw cntErr;
|
||||||
if ((count ?? 0) > 0) {
|
if ((count ?? 0) > 0) {
|
||||||
// Rows exist for this version, but none for me. Either I lost the device-key
|
// Rows exist for this version, but none usable for me. Either I lost the
|
||||||
// that originally received my bundle, or my own bundle was wiped by the
|
// device-key that originally received my bundle, my own bundle was wiped
|
||||||
// 0.18.0 reset_user_key bug. Either way, the only way out is to mint a fresh
|
// by the 0.18.0 reset_user_key bug, or my key was reset and the existing
|
||||||
// conv-key at version+1 and wrap it for everyone we can. Old messages stay
|
// bundle is unwrappable (handled in the try/catch above). The only way
|
||||||
// unreadable for me; new ones flow.
|
// out is to mint a fresh conv-key at version+1 and wrap it for everyone
|
||||||
console.info('[conv-key] no bundle for me at v' + version + ' — auto-rotating');
|
// 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 rotateConvKey(client, conversationId, own);
|
||||||
}
|
}
|
||||||
return bootstrapConvKey(client, conversationId, own, version);
|
return bootstrapConvKey(client, conversationId, own, version);
|
||||||
@@ -248,9 +262,24 @@ export async function tryGetConvKey(
|
|||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const bundle = await fetchKeyBundle(client, conversationId, ownUserId, keyVersion);
|
const bundle = await fetchKeyBundle(client, conversationId, ownUserId, keyVersion);
|
||||||
if (!bundle) return null;
|
if (!bundle) return null;
|
||||||
const key = await unwrapConvKey(
|
let key: Uint8Array;
|
||||||
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, ownPrivateKey,
|
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 };
|
const handle = { conversationId, keyVersion, key };
|
||||||
cache.set(cacheKey(conversationId, keyVersion), handle);
|
cache.set(cacheKey(conversationId, keyVersion), handle);
|
||||||
return handle;
|
return handle;
|
||||||
|
|||||||
Reference in New Issue
Block a user