fix(desktop): mask scrolled content behind channel and thread composers (#2163)

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
thomaspblock
2026-07-20 13:37:35 +02:00
committed by GitHub
parent 73ac59a033
commit 6ecc89468d
4 changed files with 152 additions and 2 deletions
+1
View File
@@ -26,6 +26,7 @@ export default defineConfig({
"**/navigation.spec.ts",
"**/channels.spec.ts",
"**/channel-shared-header-backdrop.spec.ts",
"**/channel-composer-overflow.spec.ts",
"**/badge.spec.ts",
"**/channel-browser.spec.ts",
"**/channel-add-screenshots.spec.ts",
@@ -730,7 +730,7 @@ export const ChannelPane = React.memo(function ChannelPane({
</div>
) : (
<div
className="pointer-events-none absolute inset-x-0 bottom-0 z-40"
className="pointer-events-none absolute inset-x-0 bottom-0 z-40 isolate before:absolute before:inset-x-0 before:bottom-0 before:-z-10 before:h-24 before:bg-gradient-to-b before:from-transparent before:to-background before:content-[''] after:absolute after:inset-x-0 after:bottom-0 after:-z-10 after:h-12 after:bg-background after:content-['']"
data-testid="channel-composer-overlay"
ref={composerWrapperRef}
>
@@ -810,7 +810,7 @@ export function MessageThreadPanel({
) : null}
<div
className="pointer-events-none absolute inset-x-0 bottom-0 z-40"
className="pointer-events-none absolute inset-x-0 bottom-0 z-40 isolate before:absolute before:inset-x-0 before:bottom-0 before:-z-10 before:h-24 before:bg-gradient-to-b before:from-transparent before:to-background before:content-[''] after:absolute after:inset-x-0 after:bottom-0 after:-z-10 after:h-12 after:bg-background after:content-['']"
data-testid="thread-composer-overlay"
ref={threadComposerWrapperRef}
>
@@ -0,0 +1,149 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
// The channel and thread composers float over their conversation scrollers.
// When the conversation is scrolled up, later rows pass underneath the
// composer overlay — the overlay must mask them (gradient fade + solid
// bottom strip, mirroring the inbox treatment from PR #2143) instead of
// letting them bleed out below the composer box.
const CHANNEL = "general";
async function waitForMockLiveSubscription(
page: import("@playwright/test").Page,
channelName: string,
) {
await expect
.poll(() =>
page.evaluate(
(ch) =>
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: ch,
}) ?? false,
channelName,
),
)
.toBe(true);
}
async function emit(
page: import("@playwright/test").Page,
content: string,
parentEventId: string | null = null,
) {
const event = await page.evaluate(
(payload) =>
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: payload.channel,
content: payload.content,
parentEventId: payload.parentEventId,
}),
{ channel: CHANNEL, content, parentEventId },
);
if (!event) throw new Error("mock message emitter is not installed");
return event as { id: string };
}
async function expectOverlayBottomMask(
overlay: import("@playwright/test").Locator,
) {
const hasMask = await overlay.evaluate((element) => {
const after = getComputedStyle(element, "::after");
const before = getComputedStyle(element, "::before");
return after.content !== "none" && before.content !== "none";
});
expect(hasMask).toBe(true);
}
test.describe("composer overlays mask scrolled content", () => {
test("channel timeline rows fade out behind the composer", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId(`channel-${CHANNEL}`).click();
await expect(page.getByTestId("message-timeline")).toBeVisible();
await waitForMockLiveSubscription(page, CHANNEL);
for (let i = 0; i < 20; i++) {
await emit(
page,
`Channel filler ${i} — enough text to occupy vertical space so the conversation scrolls and rows pass behind the composer overlay.`,
);
}
await page.waitForTimeout(400);
// Scroll the conversation up so trailing rows sit behind the overlay.
await page.evaluate(() => {
const scroller = document.querySelector<HTMLElement>(
'[data-buzz-conversation-scroll="true"]',
);
if (!scroller) throw new Error("Missing conversation scroll container");
scroller.scrollTop = Math.max(
0,
scroller.scrollHeight - scroller.clientHeight - 220,
);
});
await page.waitForTimeout(300);
await waitForAnimations(page);
await page.screenshot({
path: "test-results/channel-overflow/channel-composer.png",
clip: { x: 300, y: 720 - 280, width: 980, height: 280 },
});
await expectOverlayBottomMask(page.getByTestId("channel-composer-overlay"));
});
test("thread panel replies fade out behind the reply composer", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId(`channel-${CHANNEL}`).click();
await expect(page.getByTestId("message-timeline")).toBeVisible();
await waitForMockLiveSubscription(page, CHANNEL);
const root = await emit(page, "Thread root — the discussion begins here.");
for (let i = 0; i < 20; i++) {
await emit(
page,
`Thread reply ${i} — enough text to occupy vertical space so the thread body scrolls and replies pass behind the reply composer.`,
root.id,
);
}
await page.waitForTimeout(400);
const summary = page.locator(
`[data-testid="message-thread-summary"][data-thread-head-id="${root.id}"]`,
);
await expect(summary).toBeVisible();
await summary.click();
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
await waitForAnimations(page);
// Scroll the thread body up so trailing replies sit behind the overlay.
await page.evaluate(() => {
const scroller = document.querySelector<HTMLElement>(
'[data-testid="message-thread-body"]',
);
if (!scroller) throw new Error("Missing thread body scroll container");
scroller.scrollTop = Math.max(
0,
scroller.scrollHeight - scroller.clientHeight - 220,
);
});
await page.waitForTimeout(300);
await waitForAnimations(page);
await page.screenshot({
path: "test-results/channel-overflow/thread-composer.png",
clip: { x: 1280 - 560, y: 720 - 300, width: 560, height: 300 },
});
await expectOverlayBottomMask(page.getByTestId("thread-composer-overlay"));
});
});