Improve chat scrolling and multiline composer (#14)

This commit is contained in:
Wes
2026-03-10 09:46:08 -07:00
committed by GitHub
parent 431c9f2b46
commit 758049ca31
6 changed files with 840 additions and 44 deletions
@@ -2,7 +2,7 @@ import { Paperclip, SendHorizontal, SmilePlus } from "lucide-react";
import * as React from "react";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
type MessageComposerProps = {
channelName: string;
@@ -12,6 +12,8 @@ type MessageComposerProps = {
placeholder?: string;
};
const MAX_TEXTAREA_ROWS = 4;
export function MessageComposer({
channelName,
disabled = false,
@@ -20,27 +22,85 @@ export function MessageComposer({
placeholder,
}: MessageComposerProps) {
const [content, setContent] = React.useState("");
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const pendingSelectionRef = React.useRef<number | null>(null);
const submitMessage = React.useCallback(async () => {
const trimmed = content.trim();
if (!trimmed || disabled || isSending) {
return;
}
setContent("");
try {
await onSend(trimmed);
} catch {
setContent(trimmed);
}
}, [content, disabled, isSending, onSend]);
const handleSubmit = React.useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
void submitMessage();
},
[submitMessage],
);
const trimmed = content.trim();
if (!trimmed || disabled || isSending) {
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key !== "Enter" || event.nativeEvent.isComposing) {
return;
}
setContent("");
if (event.ctrlKey) {
const textarea = event.currentTarget;
const { selectionEnd, selectionStart, value } = textarea;
const nextContent = `${value.slice(0, selectionStart)}\n${value.slice(selectionEnd)}`;
try {
await onSend(trimmed);
} catch {
setContent(trimmed);
event.preventDefault();
pendingSelectionRef.current = selectionStart + 1;
setContent(nextContent);
return;
}
if (event.metaKey || event.altKey || event.shiftKey) {
return;
}
event.preventDefault();
void submitMessage();
},
[content, disabled, isSending, onSend],
[submitMessage],
);
React.useLayoutEffect(() => {
const textarea = textareaRef.current;
if (!textarea) {
return;
}
const lineHeight =
Number.parseFloat(window.getComputedStyle(textarea).lineHeight) || 24;
const maxHeight = lineHeight * MAX_TEXTAREA_ROWS;
textarea.style.height = "auto";
const nextHeight = Math.max(
lineHeight,
Math.min(textarea.scrollHeight, maxHeight),
);
textarea.style.height = `${nextHeight}px`;
textarea.style.overflowY =
textarea.scrollHeight > maxHeight ? "auto" : "hidden";
const pendingSelection = pendingSelectionRef.current;
if (pendingSelection !== null) {
textarea.setSelectionRange(pendingSelection, pendingSelection);
pendingSelectionRef.current = null;
}
});
return (
<footer className="border-t border-border/80 bg-background p-4">
<div className="mx-auto flex w-full max-w-4xl flex-col gap-3">
@@ -48,16 +108,19 @@ export function MessageComposer({
className="rounded-2xl border border-input bg-card px-3 py-4 shadow-sm sm:px-4"
data-testid="message-composer"
onSubmit={(event) => {
void handleSubmit(event);
handleSubmit(event);
}}
>
<Input
<Textarea
aria-label="Message channel"
className="h-auto border-0 bg-transparent px-0 py-0 text-sm leading-6 shadow-none focus-visible:ring-0"
className="min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-6 shadow-none focus-visible:ring-0"
data-testid="message-input"
disabled={disabled}
onChange={(event) => setContent(event.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder ?? `Message #${channelName}`}
ref={textareaRef}
rows={1}
value={content}
/>
@@ -75,6 +138,7 @@ export function MessageComposer({
className="gap-2"
data-testid="send-message"
disabled={disabled || isSending || content.trim().length === 0}
title="Send (Enter)"
type="submit"
>
<SendHorizontal className="h-4 w-4" />
@@ -1,5 +1,9 @@
import { ArrowDown } from "lucide-react";
import * as React from "react";
import type { TimelineMessage } from "@/features/messages/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Markdown } from "@/shared/ui/markdown";
import { Separator } from "@/shared/ui/separator";
import { Skeleton } from "@/shared/ui/skeleton";
@@ -11,6 +15,15 @@ type MessageTimelineProps = {
emptyDescription?: string;
};
const BOTTOM_THRESHOLD_PX = 72;
function isNearBottom(container: HTMLDivElement) {
return (
container.scrollHeight - container.clientHeight - container.scrollTop <=
BOTTOM_THRESHOLD_PX
);
}
function MessageRow({ message }: { message: TimelineMessage }) {
const initials = message.author
.split(" ")
@@ -82,42 +95,329 @@ export function MessageTimeline({
emptyTitle = "No messages yet",
emptyDescription = "Send the first message to start the thread.",
}: MessageTimelineProps) {
const timelineRef = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
const bottomAnchorRef = React.useRef<HTMLDivElement>(null);
const hasInitializedRef = React.useRef(false);
const shouldStickToBottomRef = React.useRef(true);
const isAtBottomRef = React.useRef(true);
const isProgrammaticBottomScrollRef = React.useRef(false);
const previousTimelineHeightRef = React.useRef<number | null>(null);
const previousScrollTopRef = React.useRef(0);
const lockedScrollTopRef = React.useRef<number | null>(null);
const previousLastMessageIdRef = React.useRef<string | undefined>(undefined);
const previousMessageCountRef = React.useRef(0);
const [isAtBottom, setIsAtBottom] = React.useState(true);
const [newMessageCount, setNewMessageCount] = React.useState(0);
const latestMessage =
messages.length > 0 ? messages[messages.length - 1] : undefined;
const syncScrollState = React.useCallback(() => {
const timeline = timelineRef.current;
if (!timeline) {
return;
}
const scrollTop = lockedScrollTopRef.current ?? timeline.scrollTop;
const atBottom = isNearBottom(timeline);
const movedAwayFromBottom = scrollTop + 1 < previousScrollTopRef.current;
if (isProgrammaticBottomScrollRef.current) {
previousScrollTopRef.current = scrollTop;
if (movedAwayFromBottom) {
isProgrammaticBottomScrollRef.current = false;
} else if (!atBottom) {
shouldStickToBottomRef.current = true;
isAtBottomRef.current = true;
setIsAtBottom((current) => (current ? current : true));
return;
} else {
isProgrammaticBottomScrollRef.current = false;
shouldStickToBottomRef.current = true;
isAtBottomRef.current = true;
setIsAtBottom((current) => (current ? current : true));
setNewMessageCount(0);
return;
}
}
if (shouldStickToBottomRef.current && !atBottom && !movedAwayFromBottom) {
previousScrollTopRef.current = scrollTop;
shouldStickToBottomRef.current = true;
isAtBottomRef.current = true;
setIsAtBottom((current) => (current ? current : true));
setNewMessageCount(0);
return;
}
previousScrollTopRef.current = scrollTop;
shouldStickToBottomRef.current = atBottom;
isAtBottomRef.current = atBottom;
setIsAtBottom((current) => (current === atBottom ? current : atBottom));
if (atBottom) {
setNewMessageCount(0);
}
}, []);
const restoreScrollPosition = React.useCallback(
(scrollTop: number) => {
const timeline = timelineRef.current;
if (!timeline) {
return;
}
isProgrammaticBottomScrollRef.current = false;
lockedScrollTopRef.current = scrollTop;
const restore = (remainingFrames: number) => {
timeline.scrollTop = scrollTop;
if (remainingFrames > 0) {
requestAnimationFrame(() => {
restore(remainingFrames - 1);
});
return;
}
lockedScrollTopRef.current = null;
previousScrollTopRef.current = timeline.scrollTop;
syncScrollState();
};
restore(2);
},
[syncScrollState],
);
const scrollToBottom = React.useCallback(
(behavior: ScrollBehavior) => {
const timeline = timelineRef.current;
if (!timeline) {
return;
}
isProgrammaticBottomScrollRef.current = true;
const alignToBottom = (nextBehavior: ScrollBehavior) => {
bottomAnchorRef.current?.scrollIntoView({
block: "end",
behavior: nextBehavior,
});
timeline.scrollTo({
top: timeline.scrollHeight,
behavior: nextBehavior,
});
};
alignToBottom(behavior);
lockedScrollTopRef.current = null;
previousScrollTopRef.current = timeline.scrollTop;
shouldStickToBottomRef.current = true;
isAtBottomRef.current = true;
setIsAtBottom(true);
setNewMessageCount(0);
if (behavior === "smooth") {
requestAnimationFrame(() => {
previousScrollTopRef.current = timeline.scrollTop;
syncScrollState();
});
return;
}
const settleAlignment = (remainingFrames: number) => {
requestAnimationFrame(() => {
alignToBottom("auto");
previousScrollTopRef.current = timeline.scrollTop;
if (remainingFrames > 0) {
settleAlignment(remainingFrames - 1);
return;
}
syncScrollState();
});
};
settleAlignment(2);
},
[syncScrollState],
);
React.useEffect(() => {
const timeline = timelineRef.current;
if (!timeline || typeof ResizeObserver === "undefined") {
return;
}
previousTimelineHeightRef.current = timeline.clientHeight;
previousScrollTopRef.current = timeline.scrollTop;
const observer = new ResizeObserver(([entry]) => {
const previousTimelineHeight = previousTimelineHeightRef.current;
const nextTimelineHeight = entry.contentRect.height;
previousTimelineHeightRef.current = nextTimelineHeight;
if (
previousTimelineHeight === null ||
Math.abs(nextTimelineHeight - previousTimelineHeight) < 1
) {
return;
}
if (shouldStickToBottomRef.current || isAtBottomRef.current) {
scrollToBottom("auto");
return;
}
restoreScrollPosition(previousScrollTopRef.current);
});
observer.observe(timeline);
return () => {
observer.disconnect();
};
}, [restoreScrollPosition, scrollToBottom]);
React.useEffect(() => {
const content = contentRef.current;
if (!content || typeof ResizeObserver === "undefined") {
return;
}
const observer = new ResizeObserver(() => {
if (shouldStickToBottomRef.current) {
scrollToBottom("auto");
return;
}
syncScrollState();
});
observer.observe(content);
return () => {
observer.disconnect();
};
}, [scrollToBottom, syncScrollState]);
React.useLayoutEffect(() => {
if (!hasInitializedRef.current) {
if (isLoading) {
return;
}
scrollToBottom("auto");
hasInitializedRef.current = true;
previousLastMessageIdRef.current = latestMessage?.id;
previousMessageCountRef.current = messages.length;
return;
}
const previousLastMessageId = previousLastMessageIdRef.current;
const previousMessageCount = previousMessageCountRef.current;
const hasNewLatestMessage =
latestMessage !== undefined && latestMessage.id !== previousLastMessageId;
if (!hasNewLatestMessage) {
previousLastMessageIdRef.current = latestMessage?.id;
previousMessageCountRef.current = messages.length;
return;
}
if (
shouldStickToBottomRef.current ||
isAtBottomRef.current ||
latestMessage.accent
) {
scrollToBottom(latestMessage.accent ? "smooth" : "auto");
} else {
setNewMessageCount((current) => {
const addedMessages = Math.max(
1,
messages.length - previousMessageCount,
);
return current + addedMessages;
});
}
previousLastMessageIdRef.current = latestMessage.id;
previousMessageCountRef.current = messages.length;
}, [isLoading, latestMessage, messages.length, scrollToBottom]);
return (
<div
className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-4 sm:px-6"
data-testid="message-timeline"
>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4">
<div className="flex items-center gap-4">
<Separator className="flex-1" />
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
Today
</p>
<Separator className="flex-1" />
</div>
{isLoading ? <TimelineSkeleton /> : null}
{!isLoading && messages.length === 0 ? (
<div className="relative flex-1 min-h-0">
<div
className="h-full overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-4 [overflow-anchor:none] sm:px-6"
data-testid="message-timeline"
onScroll={syncScrollState}
ref={timelineRef}
>
<div
className="mx-auto flex w-full max-w-4xl flex-col gap-4"
ref={contentRef}
>
<div
className="rounded-3xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center shadow-sm"
data-testid="message-empty"
className="flex items-center gap-4"
data-testid="message-timeline-day-divider"
>
<p className="text-base font-semibold tracking-tight">
{emptyTitle}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{emptyDescription}
<Separator className="flex-1" />
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
Today
</p>
<Separator className="flex-1" />
</div>
) : null}
{!isLoading
? messages.map((message) => (
<MessageRow key={message.id} message={message} />
))
: null}
{isLoading ? <TimelineSkeleton /> : null}
{!isLoading && messages.length === 0 ? (
<div
className="rounded-3xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center shadow-sm"
data-testid="message-empty"
>
<p className="text-base font-semibold tracking-tight">
{emptyTitle}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{emptyDescription}
</p>
</div>
) : null}
{!isLoading
? messages.map((message) => (
<MessageRow key={message.id} message={message} />
))
: null}
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
</div>
</div>
{!isAtBottom ? (
<div className="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-4">
<Button
className="pointer-events-auto rounded-full shadow-lg"
data-testid="message-scroll-to-latest"
onClick={() => {
scrollToBottom("smooth");
}}
size="sm"
type="button"
>
<ArrowDown className="h-4 w-4" />
{newMessageCount > 0
? `${newMessageCount} new message${newMessageCount === 1 ? "" : "s"}`
: "Jump to latest"}
</Button>
</div>
) : null}
</div>
);
}