Compare commits

..

4 Commits

4 changed files with 67 additions and 10 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@chat-app/desktop", "name": "@chat-app/desktop",
"version": "0.21.0", "version": "0.21.1",
"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')
+47 -6
View File
@@ -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 (