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:
@@ -65,6 +65,8 @@ export function AppShell() {
|
||||
? `${activeChannel.description} Forum channels are listed, but this first pass only wires message streams and DMs.`
|
||||
: activeChannel.description
|
||||
: "Connect to the relay to browse channels and read messages.";
|
||||
const contentPaneKey =
|
||||
selectedView === "home" ? "home" : `channel:${activeChannel?.id ?? "none"}`;
|
||||
|
||||
return (
|
||||
<SidebarProvider className="h-dvh overflow-hidden overscroll-none">
|
||||
@@ -108,7 +110,10 @@ export function AppShell() {
|
||||
selectedView={selectedView}
|
||||
/>
|
||||
|
||||
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
|
||||
<SidebarInset
|
||||
className="min-h-0 min-w-0 overflow-hidden"
|
||||
key={contentPaneKey}
|
||||
>
|
||||
{selectedView === "home" ? (
|
||||
<ChatHeader
|
||||
description="Personalized feed for mentions, reminders, channel activity, and agent work."
|
||||
@@ -162,6 +167,7 @@ export function AppShell() {
|
||||
: "No channel selected"
|
||||
}
|
||||
isLoading={messagesQuery.isLoading}
|
||||
key={activeChannel?.id ?? "no-channel"}
|
||||
messages={timelineMessages}
|
||||
/>
|
||||
<MessageComposer
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-20 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
@@ -2,6 +2,44 @@ import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
async function getTimelineMetrics(page: import("@playwright/test").Page) {
|
||||
return page.getByTestId("message-timeline").evaluate((element) => {
|
||||
const timeline = element as HTMLDivElement;
|
||||
|
||||
return {
|
||||
clientHeight: timeline.clientHeight,
|
||||
scrollHeight: timeline.scrollHeight,
|
||||
scrollTop: timeline.scrollTop,
|
||||
distanceFromBottom:
|
||||
timeline.scrollHeight - timeline.clientHeight - timeline.scrollTop,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureTimelineScrollable(
|
||||
page: import("@playwright/test").Page,
|
||||
prefix: string,
|
||||
) {
|
||||
const input = page.getByTestId("message-input");
|
||||
const sendButton = page.getByTestId("send-message");
|
||||
|
||||
for (let index = 0; index < 24; index += 1) {
|
||||
const metrics = await getTimelineMetrics(page);
|
||||
if (metrics.scrollHeight > metrics.clientHeight + 160) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = `${prefix} seed ${index}`;
|
||||
|
||||
await input.fill(message);
|
||||
await sendButton.click();
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
||||
}
|
||||
|
||||
const metrics = await getTimelineMetrics(page);
|
||||
expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight + 160);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
});
|
||||
@@ -53,6 +91,31 @@ test("opens a mocked channel from the home feed", async ({ page }) => {
|
||||
);
|
||||
});
|
||||
|
||||
test("replaces the channel pane when switching channels", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(
|
||||
"Welcome to #general",
|
||||
);
|
||||
|
||||
await page.getByTestId("channel-random").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
||||
await expect(page.getByTestId("message-empty")).toBeVisible();
|
||||
await expect(page.getByTestId("message-timeline")).not.toContainText(
|
||||
"Welcome to #general",
|
||||
);
|
||||
await expect(page.getByTestId("message-timeline")).toHaveCount(1);
|
||||
await expect(page.getByTestId("message-timeline-day-divider")).toHaveCount(1);
|
||||
|
||||
await page.getByTestId("channel-engineering").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
|
||||
await expect(page.getByTestId("message-empty")).toBeVisible();
|
||||
await expect(page.getByTestId("message-timeline")).toHaveCount(1);
|
||||
await expect(page.getByTestId("message-timeline-day-divider")).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("sends a mocked channel message", async ({ page }) => {
|
||||
const message = `Smoke message ${Date.now()}`;
|
||||
|
||||
@@ -64,3 +127,82 @@ test("sends a mocked channel message", async ({ page }) => {
|
||||
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
||||
});
|
||||
|
||||
test("supports multiline drafts with Ctrl+Enter and sends with Enter", async ({
|
||||
page,
|
||||
}) => {
|
||||
const firstLine = `Shortcut smoke line one ${Date.now()}`;
|
||||
const restOfLines = [
|
||||
"Shortcut smoke line two",
|
||||
"Shortcut smoke line three",
|
||||
"Shortcut smoke line four",
|
||||
"Shortcut smoke line five",
|
||||
];
|
||||
const input = page.getByTestId("message-input");
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(page.getByTestId("send-message")).toContainText("Send");
|
||||
const initialInputHeight = await input.evaluate(
|
||||
(element) => (element as HTMLTextAreaElement).clientHeight,
|
||||
);
|
||||
expect(initialInputHeight).toBeLessThan(40);
|
||||
await input.fill(firstLine);
|
||||
for (const line of restOfLines) {
|
||||
await input.press("Control+Enter");
|
||||
await input.type(line);
|
||||
}
|
||||
await expect(input).toHaveValue([firstLine, ...restOfLines].join("\n"));
|
||||
const expandedInputHeight = await input.evaluate(
|
||||
(element) => (element as HTMLTextAreaElement).clientHeight,
|
||||
);
|
||||
expect(expandedInputHeight).toBeLessThanOrEqual(100);
|
||||
await expect(page.getByTestId("message-timeline")).not.toContainText(
|
||||
firstLine,
|
||||
);
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(firstLine);
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(
|
||||
restOfLines[restOfLines.length - 1],
|
||||
);
|
||||
});
|
||||
|
||||
test("does not shift the timeline when the composer grows", async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = page.getByTestId("message-input");
|
||||
const prefix = `Composer growth ${Date.now()}`;
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
await ensureTimelineScrollable(page, prefix);
|
||||
await page.waitForTimeout(400);
|
||||
await page.getByTestId("message-timeline").evaluate((element) => {
|
||||
const timeline = element as HTMLDivElement;
|
||||
timeline.scrollTop = 0;
|
||||
timeline.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(page)).distanceFromBottom)
|
||||
.toBeGreaterThan(160);
|
||||
const before = await getTimelineMetrics(page);
|
||||
|
||||
await input.fill("Composer growth line one");
|
||||
await input.press("Control+Enter");
|
||||
await input.type("Composer growth line two");
|
||||
await input.press("Control+Enter");
|
||||
await input.type("Composer growth line three");
|
||||
await input.press("Control+Enter");
|
||||
await input.type("Composer growth line four");
|
||||
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
const after = await getTimelineMetrics(page);
|
||||
expect(after.clientHeight).toBeLessThan(before.clientHeight);
|
||||
expect(Math.abs(after.scrollTop - before.scrollTop)).toBeLessThanOrEqual(2);
|
||||
expect(after.distanceFromBottom).toBeGreaterThan(160);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,50 @@
|
||||
import { expect, test, type Browser } from "@playwright/test";
|
||||
import { expect, test, type Browser, type Page } from "@playwright/test";
|
||||
|
||||
import { installRelayBridge } from "../helpers/bridge";
|
||||
import { assertRelaySeeded } from "../helpers/seed";
|
||||
|
||||
async function getTimelineMetrics(page: Page) {
|
||||
return page.getByTestId("message-timeline").evaluate((element) => {
|
||||
const timeline = element as HTMLDivElement;
|
||||
|
||||
return {
|
||||
clientHeight: timeline.clientHeight,
|
||||
scrollHeight: timeline.scrollHeight,
|
||||
scrollTop: timeline.scrollTop,
|
||||
distanceFromBottom:
|
||||
timeline.scrollHeight - timeline.clientHeight - timeline.scrollTop,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureTimelineScrollable(
|
||||
senderPage: Page,
|
||||
receiverPage: Page,
|
||||
prefix: string,
|
||||
) {
|
||||
const input = senderPage.getByTestId("message-input");
|
||||
const sendButton = senderPage.getByTestId("send-message");
|
||||
|
||||
for (let index = 0; index < 24; index += 1) {
|
||||
const metrics = await getTimelineMetrics(receiverPage);
|
||||
if (metrics.scrollHeight > metrics.clientHeight + 160) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = `${prefix} seed ${index}`;
|
||||
|
||||
await expect(input).toBeEnabled();
|
||||
await input.fill(message);
|
||||
await sendButton.click();
|
||||
await expect(receiverPage.getByTestId("message-timeline")).toContainText(
|
||||
message,
|
||||
);
|
||||
}
|
||||
|
||||
const metrics = await getTimelineMetrics(receiverPage);
|
||||
expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight + 160);
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await assertRelaySeeded();
|
||||
});
|
||||
@@ -94,3 +136,223 @@ test("delivers a message to a second browser context in real time", async ({
|
||||
await contextTwo.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("stays pinned to the latest message when new messages arrive at the bottom", async ({
|
||||
browser,
|
||||
}: {
|
||||
browser: Browser;
|
||||
}) => {
|
||||
const contextOne = await browser.newContext();
|
||||
const contextTwo = await browser.newContext();
|
||||
const pageOne = await contextOne.newPage();
|
||||
const pageTwo = await contextTwo.newPage();
|
||||
const prefix = `Pinned scroll ${Date.now()}`;
|
||||
const incomingMessage = `${prefix} incoming`;
|
||||
|
||||
try {
|
||||
await installRelayBridge(pageOne, "tyler");
|
||||
await installRelayBridge(pageTwo, "alice");
|
||||
|
||||
await pageOne.goto("/");
|
||||
await pageTwo.goto("/");
|
||||
|
||||
await pageOne.getByTestId("channel-general").click();
|
||||
await pageTwo.getByTestId("channel-general").click();
|
||||
await expect(pageOne.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(pageTwo.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
await ensureTimelineScrollable(pageOne, pageTwo, prefix);
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeLessThan(8);
|
||||
|
||||
await pageOne.getByTestId("message-input").fill(incomingMessage);
|
||||
await pageOne.getByTestId("send-message").click();
|
||||
|
||||
await expect(pageTwo.getByTestId("message-timeline")).toContainText(
|
||||
incomingMessage,
|
||||
);
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeLessThan(8);
|
||||
await expect(pageTwo.getByTestId("message-scroll-to-latest")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
await contextOne.close();
|
||||
await contextTwo.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("stays pinned after you send a message and a remote reply arrives right after", async ({
|
||||
browser,
|
||||
}: {
|
||||
browser: Browser;
|
||||
}) => {
|
||||
const contextOne = await browser.newContext();
|
||||
const contextTwo = await browser.newContext();
|
||||
const pageOne = await contextOne.newPage();
|
||||
const pageTwo = await contextTwo.newPage();
|
||||
const prefix = `Reply after send ${Date.now()}`;
|
||||
const localMessage = `${prefix} local`;
|
||||
const incomingMessage = `${prefix} incoming`;
|
||||
|
||||
try {
|
||||
await installRelayBridge(pageOne, "tyler");
|
||||
await installRelayBridge(pageTwo, "alice");
|
||||
|
||||
await pageOne.goto("/");
|
||||
await pageTwo.goto("/");
|
||||
|
||||
await pageOne.getByTestId("channel-general").click();
|
||||
await pageTwo.getByTestId("channel-general").click();
|
||||
await expect(pageOne.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(pageTwo.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
await ensureTimelineScrollable(pageOne, pageTwo, prefix);
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeLessThan(8);
|
||||
|
||||
await pageTwo.getByTestId("message-input").fill(localMessage);
|
||||
await pageTwo.getByTestId("send-message").click();
|
||||
await expect(pageTwo.getByTestId("message-timeline")).toContainText(
|
||||
localMessage,
|
||||
);
|
||||
|
||||
await pageOne.getByTestId("message-input").fill(incomingMessage);
|
||||
await pageOne.getByTestId("send-message").click();
|
||||
|
||||
await expect(pageTwo.getByTestId("message-timeline")).toContainText(
|
||||
incomingMessage,
|
||||
);
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeLessThan(8);
|
||||
await expect(pageTwo.getByTestId("message-scroll-to-latest")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
await contextOne.close();
|
||||
await contextTwo.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps bottom-pinned scrolling after the composer grows", async ({
|
||||
browser,
|
||||
}: {
|
||||
browser: Browser;
|
||||
}) => {
|
||||
const contextOne = await browser.newContext();
|
||||
const contextTwo = await browser.newContext();
|
||||
const pageOne = await contextOne.newPage();
|
||||
const pageTwo = await contextTwo.newPage();
|
||||
const prefix = `Composer pinned ${Date.now()}`;
|
||||
const incomingMessage = `${prefix} incoming`;
|
||||
const receiverInput = pageTwo.getByTestId("message-input");
|
||||
|
||||
try {
|
||||
await installRelayBridge(pageOne, "tyler");
|
||||
await installRelayBridge(pageTwo, "alice");
|
||||
|
||||
await pageOne.goto("/");
|
||||
await pageTwo.goto("/");
|
||||
|
||||
await pageOne.getByTestId("channel-general").click();
|
||||
await pageTwo.getByTestId("channel-general").click();
|
||||
await expect(pageOne.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(pageTwo.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
await ensureTimelineScrollable(pageOne, pageTwo, prefix);
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeLessThan(8);
|
||||
|
||||
await receiverInput.fill("Composer pinned line one");
|
||||
await receiverInput.press("Enter");
|
||||
await receiverInput.type("Composer pinned line two");
|
||||
await receiverInput.press("Enter");
|
||||
await receiverInput.type("Composer pinned line three");
|
||||
await receiverInput.press("Enter");
|
||||
await receiverInput.type("Composer pinned line four");
|
||||
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeLessThan(8);
|
||||
|
||||
await pageOne.getByTestId("message-input").fill(incomingMessage);
|
||||
await pageOne.getByTestId("send-message").click();
|
||||
|
||||
await expect(pageTwo.getByTestId("message-timeline")).toContainText(
|
||||
incomingMessage,
|
||||
);
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeLessThan(8);
|
||||
await expect(pageTwo.getByTestId("message-scroll-to-latest")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
await contextOne.close();
|
||||
await contextTwo.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps scroll position when new messages arrive above the fold", async ({
|
||||
browser,
|
||||
}: {
|
||||
browser: Browser;
|
||||
}) => {
|
||||
const contextOne = await browser.newContext();
|
||||
const contextTwo = await browser.newContext();
|
||||
const pageOne = await contextOne.newPage();
|
||||
const pageTwo = await contextTwo.newPage();
|
||||
const prefix = `Scroll behavior ${Date.now()}`;
|
||||
const incomingMessage = `${prefix} incoming`;
|
||||
|
||||
try {
|
||||
await installRelayBridge(pageOne, "tyler");
|
||||
await installRelayBridge(pageTwo, "alice");
|
||||
|
||||
await pageOne.goto("/");
|
||||
await pageTwo.goto("/");
|
||||
|
||||
await pageOne.getByTestId("channel-general").click();
|
||||
await pageTwo.getByTestId("channel-general").click();
|
||||
await expect(pageOne.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(pageTwo.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
await ensureTimelineScrollable(pageOne, pageTwo, prefix);
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeLessThan(8);
|
||||
|
||||
await pageTwo.getByTestId("message-timeline").evaluate((element) => {
|
||||
const timeline = element as HTMLDivElement;
|
||||
timeline.scrollTop = 0;
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeGreaterThan(160);
|
||||
|
||||
await pageOne.getByTestId("message-input").fill(incomingMessage);
|
||||
await pageOne.getByTestId("send-message").click();
|
||||
|
||||
await expect(pageTwo.getByTestId("message-scroll-to-latest")).toContainText(
|
||||
"1 new message",
|
||||
);
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeGreaterThan(160);
|
||||
|
||||
await pageTwo.getByTestId("message-scroll-to-latest").click();
|
||||
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeLessThan(8);
|
||||
} finally {
|
||||
await contextOne.close();
|
||||
await contextTwo.close();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user