mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Improve chat scrolling and multiline composer (#14)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user