fix(chat): snap to bottom after send to mask composer-shrink layout shift

This commit is contained in:
byGalax
2026-05-18 14:29:31 +02:00
parent 92a6e01a26
commit e2f86bc377
+27 -4
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);