Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 508c53b451 | |||
| e2f86bc377 | |||
| 92a6e01a26 | |||
| 3d959aaadf |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chat-app/desktop",
|
||||
"version": "0.21.0",
|
||||
"version": "0.21.1",
|
||||
"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')
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user