mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Hide channel intro actions until history start (#1151)
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr
parent
0c5dbf3452
commit
7aa6daa496
@@ -456,6 +456,7 @@ test("timeline-intro-surface: skeleton suppresses intro while loading", () => {
|
||||
selectTimelineIntroSurface({
|
||||
hasChannelIntro: true,
|
||||
hasDirectMessageIntro: false,
|
||||
hasReachedChannelStart: true,
|
||||
isSkeletonVisible: true,
|
||||
}),
|
||||
null,
|
||||
@@ -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",
|
||||
@@ -497,6 +512,7 @@ test("timeline-intro-surface: no intro without an intro model", () => {
|
||||
selectTimelineIntroSurface({
|
||||
hasChannelIntro: false,
|
||||
hasDirectMessageIntro: false,
|
||||
hasReachedChannelStart: true,
|
||||
isSkeletonVisible: false,
|
||||
}),
|
||||
null,
|
||||
|
||||
@@ -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) {
|
||||
@@ -237,7 +239,7 @@ export function selectTimelineIntroSurface({
|
||||
if (hasDirectMessageIntro) {
|
||||
return "direct-message-intro";
|
||||
}
|
||||
if (hasChannelIntro) {
|
||||
if (hasChannelIntro && hasReachedChannelStart) {
|
||||
return "channel-intro";
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -218,25 +218,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,
|
||||
@@ -256,6 +242,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,
|
||||
() => ({
|
||||
|
||||
@@ -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" }
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
*
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -452,6 +471,7 @@ export function useAnchoredScroll({
|
||||
|
||||
prevLastMessageIdRef.current = lastMessage?.id;
|
||||
prevMessageCountRef.current = messages.length;
|
||||
setHasReachedTop((current) => current || isAtTimelineStartNow(container));
|
||||
}, [
|
||||
isFetchingOlder,
|
||||
isLoading,
|
||||
@@ -617,6 +637,7 @@ export function useAnchoredScroll({
|
||||
return {
|
||||
onScroll,
|
||||
isAtBottom,
|
||||
hasReachedTop,
|
||||
newMessageCount,
|
||||
highlightedMessageId,
|
||||
scrollToBottom: scrollToBottomImperative,
|
||||
|
||||
@@ -793,21 +793,100 @@ 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-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"),
|
||||
).toBeVisible();
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByTestId("welcome-composer-guide-banner")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
@@ -815,8 +894,6 @@ test("channel with messages shows content", async ({ page }) => {
|
||||
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 ({
|
||||
|
||||
Reference in New Issue
Block a user