Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 508c53b451 | |||
| e2f86bc377 | |||
| 92a6e01a26 | |||
| 3d959aaadf |
@@ -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')
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
Reference in New Issue
Block a user