Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions desktop/src/features/messages/lib/timelineSnapshot.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,7 @@ test("timeline-intro-surface: skeleton suppresses intro while loading", () => {
selectTimelineIntroSurface({
hasChannelIntro: true,
hasDirectMessageIntro: false,
hasReachedChannelStart: true,
isSkeletonVisible: true,
}),
null,
Expand All @@ -475,17 +476,31 @@ test("timeline-intro-surface: intro may coexist with the message list", () => {
selectTimelineIntroSurface({
hasChannelIntro: true,
hasDirectMessageIntro: false,
hasReachedChannelStart: true,
isSkeletonVisible: false,
}),
"channel-intro",
);
});

test("timeline-intro-surface: channel intro waits for oldest-history boundary", () => {
assert.equal(
selectTimelineIntroSurface({
hasChannelIntro: true,
hasDirectMessageIntro: false,
hasReachedChannelStart: false,
isSkeletonVisible: false,
}),
null,
);
});

test("timeline-intro-surface: direct-message intro wins over channel intro", () => {
assert.equal(
selectTimelineIntroSurface({
hasChannelIntro: true,
hasDirectMessageIntro: true,
hasReachedChannelStart: false,
isSkeletonVisible: false,
}),
"direct-message-intro",
Expand All @@ -497,6 +512,7 @@ test("timeline-intro-surface: no intro without an intro model", () => {
selectTimelineIntroSurface({
hasChannelIntro: false,
hasDirectMessageIntro: false,
hasReachedChannelStart: true,
isSkeletonVisible: false,
}),
null,
Expand Down
4 changes: 3 additions & 1 deletion desktop/src/features/messages/lib/timelineSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,10 +225,12 @@ export type TimelineIntroSurface =
export function selectTimelineIntroSurface({
hasChannelIntro,
hasDirectMessageIntro,
hasReachedChannelStart,
isSkeletonVisible,
}: {
hasChannelIntro: boolean;
hasDirectMessageIntro: boolean;
hasReachedChannelStart: boolean;
isSkeletonVisible: boolean;
}): TimelineIntroSurface {
if (isSkeletonVisible) {
Expand All @@ -237,7 +239,7 @@ export function selectTimelineIntroSurface({
if (hasDirectMessageIntro) {
return "direct-message-intro";
}
if (hasChannelIntro) {
if (hasChannelIntro && hasReachedChannelStart) {
return "channel-intro";
}
return null;
Expand Down
34 changes: 19 additions & 15 deletions desktop/src/features/messages/ui/MessageTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,25 +212,11 @@ const MessageTimelineBase = React.forwardRef<
liveCount: messages.length,
});
const showTimelineSkeleton = timelineBodySurface === "skeleton";
const timelineIntroSurface = selectTimelineIntroSurface({
hasChannelIntro: channelIntro !== null && directMessageIntro === null,
hasDirectMessageIntro: directMessageIntro !== null,
isSkeletonVisible: showTimelineSkeleton,
});
const showDirectMessageIntro =
timelineIntroSurface === "direct-message-intro";
const showChannelIntro = timelineIntroSurface === "channel-intro";
const activeDirectMessageIntro = showDirectMessageIntro
? directMessageIntro
: null;
const activeChannelIntro = showChannelIntro ? channelIntro : null;
const showIntro = showDirectMessageIntro || showChannelIntro;
const showGenericEmpty = timelineBodySurface === "empty" && !showIntro;
const showMessageList = timelineBodySurface === "list";

const {
highlightedMessageId,
isAtBottom,
hasReachedTop,
newMessageCount,
onScroll,
scrollToBottom,
Expand All @@ -250,6 +236,24 @@ const MessageTimelineBase = React.forwardRef<
targetMessageId,
});

const timelineIntroSurface = selectTimelineIntroSurface({
hasChannelIntro: channelIntro !== null && directMessageIntro === null,
hasDirectMessageIntro: directMessageIntro !== null,
hasReachedChannelStart:
(!hasOlderMessages && hasReachedTop) || messages.length === 0,
isSkeletonVisible: showTimelineSkeleton,
});
const showDirectMessageIntro =
timelineIntroSurface === "direct-message-intro";
const showChannelIntro = timelineIntroSurface === "channel-intro";
const activeDirectMessageIntro = showDirectMessageIntro
? directMessageIntro
: null;
const activeChannelIntro = showChannelIntro ? channelIntro : null;
const showIntro = showDirectMessageIntro || showChannelIntro;
const showGenericEmpty = timelineBodySurface === "empty" && !showIntro;
const showMessageList = timelineBodySurface === "list";

React.useImperativeHandle(
ref,
() => ({
Expand Down
21 changes: 21 additions & 0 deletions desktop/src/features/messages/ui/useAnchoredScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { TimelineMessage } from "@/features/messages/types";
* rounding from the layout engine.
*/
const AT_BOTTOM_THRESHOLD_PX = 32;
const AT_TOP_THRESHOLD_PX = 32;

type AnchorState =
| { kind: "at-bottom" }
Expand Down Expand Up @@ -51,6 +52,8 @@ type UseAnchoredScrollResult = {
onScroll: () => void;
/** True when the user is within `AT_BOTTOM_THRESHOLD_PX` of the bottom. */
isAtBottom: boolean;
/** True once the user has reached the top of the currently loaded timeline. */
hasReachedTop: boolean;
/** Number of new messages that have arrived while the user is not at the
* bottom. Cleared when the user returns to the bottom. */
newMessageCount: number;
Expand All @@ -76,6 +79,18 @@ function isAtBottomNow(container: HTMLDivElement) {
);
}

function isAtTopNow(container: HTMLDivElement) {
return container.scrollTop <= AT_TOP_THRESHOLD_PX;
}

function isAtTimelineStartNow(container: HTMLDivElement) {
const scrollableDistance = Math.max(
0,
container.scrollHeight - container.clientHeight,
);
return scrollableDistance <= AT_TOP_THRESHOLD_PX || isAtTopNow(container);
}

/**
* Pick an anchor for the current scroll position.
*
Expand Down Expand Up @@ -228,6 +243,7 @@ export function useAnchoredScroll({
// layout effect below so the read is consistent with what's in the DOM.
const messagesRef = React.useRef<TimelineMessage[]>(messages);
const [isAtBottom, setIsAtBottom] = React.useState(true);
const [hasReachedTop, setHasReachedTop] = React.useState(false);
const [newMessageCount, setNewMessageCount] = React.useState(0);
const [highlightedMessageId, setHighlightedMessageId] = React.useState<
string | null
Expand All @@ -252,6 +268,7 @@ export function useAnchoredScroll({
React.useLayoutEffect(() => {
anchorRef.current = { kind: "at-bottom" };
setIsAtBottom(true);
setHasReachedTop(false);
setNewMessageCount(0);
setHighlightedMessageId(null);
hasInitializedRef.current = false;
Expand Down Expand Up @@ -355,6 +372,7 @@ export function useAnchoredScroll({
anchorRef.current = computeAnchor(container);
const atBottom = anchorRef.current.kind === "at-bottom";
setIsAtBottom((prev) => (prev === atBottom ? prev : atBottom));
setHasReachedTop((current) => current || isAtTimelineStartNow(container));
if (atBottom) {
setNewMessageCount(0);
}
Expand Down Expand Up @@ -402,6 +420,7 @@ export function useAnchoredScroll({
scrollToBottomImperative("auto");
}
hasInitializedRef.current = true;
setHasReachedTop((current) => current || isAtTimelineStartNow(container));
prevLastMessageIdRef.current = messages[messages.length - 1]?.id;
prevMessageCountRef.current = messages.length;
return;
Expand Down Expand Up @@ -452,6 +471,7 @@ export function useAnchoredScroll({

prevLastMessageIdRef.current = lastMessage?.id;
prevMessageCountRef.current = messages.length;
setHasReachedTop((current) => current || isAtTimelineStartNow(container));
}, [
isFetchingOlder,
isLoading,
Expand Down Expand Up @@ -617,6 +637,7 @@ export function useAnchoredScroll({
return {
onScroll,
isAtBottom,
hasReachedTop,
newMessageCount,
highlightedMessageId,
scrollToBottom: scrollToBottomImperative,
Expand Down
85 changes: 81 additions & 4 deletions desktop/tests/e2e/channels.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -793,30 +793,107 @@ test("empty channel shows intro actions", async ({ page }) => {
);
});

test("channel with messages shows content", async ({ page }) => {
test("short channel with messages shows intro actions on open", async ({
page,
}) => {
const channelName = `short-intro-${Date.now()}`;
const message = "Only message in a short channel";

await page.goto("/");
await page.getByRole("button", { name: "Create a channel" }).click();
await page.getByTestId("create-channel-name").fill(channelName);
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);

await page.getByTestId("message-input").fill(message);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(message);

await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId(`channel-${channelName}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await expect(page.getByTestId("message-timeline")).toContainText(message);
await expect(page.getByTestId("message-channel-intro")).toBeVisible();
await expect(page.getByTestId("message-channel-intro")).toContainText(
"This is the beginning of the regular channel.",
);
await expect(
page.getByTestId("channel-intro-action-create-channel"),
page.getByTestId("channel-intro-action-create-agent"),
).toBeVisible();
await expect(
page.getByTestId("channel-intro-action-add-people"),
).toBeVisible();
});

test("scrollable channel with recent messages hides intro actions until top", async ({
page,
}) => {
const channelName = `long-intro-${Date.now()}`;
const messages = Array.from(
{ length: 24 },
(_, index) => `Scrollable channel message ${index + 1}`,
);

await page.goto("/");
await page.getByRole("button", { name: "Create a channel" }).click();
await page.getByTestId("create-channel-name").fill(channelName);
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);

for (const message of messages) {
await page.getByTestId("message-input").fill(message);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(message);
}

await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId(`channel-${channelName}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await expect(page.getByTestId("message-channel-intro")).toHaveCount(0);
await expect(
page.getByTestId("channel-intro-action-create-agent"),
).toHaveCount(0);
await expect(page.getByTestId("channel-intro-action-add-people")).toHaveCount(
0,
);
await expect(page.getByTestId("message-timeline")).toContainText(
messages[messages.length - 1],
);

await page.getByTestId("message-timeline").evaluate((element) => {
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await expect(page.getByTestId("message-channel-intro")).toBeVisible();
await expect(
page.getByTestId("channel-intro-action-create-agent"),
).toBeVisible();
await expect(
page.getByTestId("channel-intro-action-add-people"),
).toBeVisible();
});

test("channel with messages shows content", async ({ page }) => {
await page.goto("/");

await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("message-channel-intro")).toHaveCount(0);
await expect(
page.getByTestId("channel-intro-action-create-channel"),
).toHaveCount(0);
await expect(
page.getByTestId("channel-intro-action-create-agent"),
).toHaveCount(0);
await expect(page.getByTestId("welcome-composer-guide-banner")).toHaveCount(
0,
);
await expect(page.getByTestId("message-timeline-day-divider")).toBeVisible();
await expect(page.getByTestId("message-timeline")).toContainText(
"Welcome to #general",
);
await expectSameLeftInset(page, "message-channel-intro", "message-row");
await expectIntroBalancedAroundDayDivider(page, "message-channel-intro");
});

test("shows and clears activity indicators for active channel agents", async ({
Expand Down