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
+7 -1
View File
@@ -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>
);
}
+22
View File
@@ -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 };
+142
View File
@@ -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);
});
+263 -1
View File
@@ -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();
}
});