Skip to content

Commit 0c719f6

Browse files
committed
Constrain macOS overscroll to conversations
Co-authored-by: Thomas Petersen <thomasp@squareup.com> Signed-off-by: Thomas Petersen <thomasp@squareup.com>
1 parent e7d43dc commit 0c719f6

6 files changed

Lines changed: 161 additions & 1 deletion

File tree

desktop/playwright.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export default defineConfig({
5252
"**/reminders.spec.ts",
5353
"**/virtualization.spec.ts",
5454
"**/scroll-history.spec.ts",
55+
"**/overscroll-boundary.spec.ts",
5556
],
5657
use: {
5758
...devices["Desktop Chrome"],

desktop/src/app/AppShell.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import { relayClient } from "@/shared/api/relayClient";
6868
import { useIdentityQuery } from "@/shared/api/hooks";
6969
import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal";
7070
import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup";
71+
import { useWebviewScrollBoundaryLock } from "@/shared/hooks/useWebviewScrollBoundaryLock";
7172
import { joinChannel } from "@/shared/api/tauri";
7273
import type { SearchHit } from "@/shared/api/types";
7374
import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext";
@@ -87,6 +88,7 @@ const LazySettingsScreen = React.lazy(async () => {
8788
export function AppShell() {
8889
useWebviewZoomShortcuts();
8990
useTauriWindowDrag();
91+
useWebviewScrollBoundaryLock();
9092

9193
const workspacesHook = useWorkspaces();
9294
const [isAddWorkspaceOpen, setIsAddWorkspaceOpen] = React.useState(false);

desktop/src/features/messages/ui/MessageThreadPanel.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,7 @@ export function MessageThreadPanel({
612612
isSplitLayout && auxiliaryPanelContentPaddingClass,
613613
!isSplitLayout && !isFloatingOverlay && "pt-[3.25rem]",
614614
)}
615+
data-buzz-conversation-scroll
615616
data-testid="message-thread-body"
616617
onScroll={onScroll}
617618
ref={threadBodyRef}

desktop/src/features/messages/ui/MessageTimeline.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -523,9 +523,10 @@ const MessageTimelineBase = React.forwardRef<
523523
) : null}
524524
<div
525525
className={cn(
526-
"absolute inset-0 overflow-y-auto overflow-x-hidden overscroll-none px-2 pt-1 [overflow-anchor:none]",
526+
"absolute inset-0 overflow-y-auto overflow-x-hidden overscroll-contain px-2 pt-1 [overflow-anchor:none]",
527527
hasComposerOverlay ? "pb-24" : "pb-4",
528528
)}
529+
data-buzz-conversation-scroll
529530
data-scroll-restoration-id={scrollRestorationId}
530531
data-testid="message-timeline"
531532
key={scrollContainerDomKey}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import * as React from "react";
2+
3+
const BOUNDARY_EPSILON_PX = 1;
4+
const CONVERSATION_SCROLL_SELECTOR = "[data-buzz-conversation-scroll]";
5+
const SCROLLABLE_OVERFLOW_VALUES = new Set(["auto", "scroll", "overlay"]);
6+
7+
function isHTMLElement(value: EventTarget | null): value is HTMLElement {
8+
return value instanceof HTMLElement;
9+
}
10+
11+
function isDocumentElement(element: HTMLElement) {
12+
return element === document.body || element === document.documentElement;
13+
}
14+
15+
function isScrollableY(element: HTMLElement) {
16+
if (isDocumentElement(element)) {
17+
return false;
18+
}
19+
20+
const style = window.getComputedStyle(element);
21+
if (!SCROLLABLE_OVERFLOW_VALUES.has(style.overflowY)) {
22+
return false;
23+
}
24+
25+
return element.scrollHeight > element.clientHeight + BOUNDARY_EPSILON_PX;
26+
}
27+
28+
function canScrollY(element: HTMLElement, deltaY: number) {
29+
if (deltaY < 0) {
30+
return element.scrollTop > BOUNDARY_EPSILON_PX;
31+
}
32+
33+
const maxScrollTop = element.scrollHeight - element.clientHeight;
34+
return element.scrollTop < maxScrollTop - BOUNDARY_EPSILON_PX;
35+
}
36+
37+
function isConversationScroller(element: HTMLElement) {
38+
return Boolean(element.closest(CONVERSATION_SCROLL_SELECTOR));
39+
}
40+
41+
/**
42+
* Stops macOS/WKWebView rubber-band gestures from escaping into the viewport.
43+
*
44+
* Buzz is laid out as fixed-height nested panes. On macOS, a wheel/trackpad
45+
* gesture that starts over a non-scrollable pane (or over a scrollable pane at
46+
* its boundary) can still be handed to the WKWebView viewport, which rubber-
47+
* bands the entire app and reveals a blank strip above/below the UI. CSS
48+
* `overscroll-behavior` is not enough for all of the empty/header/footer hit
49+
* targets in the webview, so this capture listener consumes only gestures that
50+
* otherwise have nowhere app-local to scroll.
51+
*
52+
* Real scrolling is left alone: if any scroll container under the pointer can
53+
* move in the wheel direction, the browser handles it normally. At boundaries,
54+
* only containers marked with `data-buzz-conversation-scroll` are allowed to
55+
* receive the gesture so their own local elastic affordance can remain; every
56+
* other boundary is locked and cannot chain to the viewport.
57+
*/
58+
export function useWebviewScrollBoundaryLock() {
59+
React.useEffect(() => {
60+
function handleWheel(event: WheelEvent) {
61+
if (event.defaultPrevented || event.deltaY === 0 || event.ctrlKey) {
62+
return;
63+
}
64+
65+
const path = event.composedPath();
66+
let firstScrollable: HTMLElement | null = null;
67+
68+
for (const target of path) {
69+
if (!isHTMLElement(target) || !isScrollableY(target)) {
70+
continue;
71+
}
72+
73+
firstScrollable ??= target;
74+
if (canScrollY(target, event.deltaY)) {
75+
return;
76+
}
77+
}
78+
79+
if (firstScrollable && isConversationScroller(firstScrollable)) {
80+
return;
81+
}
82+
83+
event.preventDefault();
84+
event.stopPropagation();
85+
}
86+
87+
window.addEventListener("wheel", handleWheel, {
88+
capture: true,
89+
passive: false,
90+
});
91+
return () => {
92+
window.removeEventListener("wheel", handleWheel, { capture: true });
93+
};
94+
}, []);
95+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
import { installMockBridge } from "../helpers/bridge";
4+
5+
async function dispatchWheelPrevented(
6+
page: import("@playwright/test").Page,
7+
selector: string,
8+
deltaY: number,
9+
) {
10+
return page.evaluate(
11+
({ selector, deltaY }) => {
12+
const element = document.querySelector(selector);
13+
if (!element) {
14+
throw new Error(`Missing element for selector: ${selector}`);
15+
}
16+
17+
const event = new WheelEvent("wheel", {
18+
bubbles: true,
19+
cancelable: true,
20+
deltaY,
21+
});
22+
element.dispatchEvent(event);
23+
return event.defaultPrevented;
24+
},
25+
{ selector, deltaY },
26+
);
27+
}
28+
29+
test.beforeEach(async ({ page }) => {
30+
await installMockBridge(page);
31+
});
32+
33+
test("locks viewport rubber-band outside conversation scrollers", async ({
34+
page,
35+
}) => {
36+
await page.goto("/");
37+
await page.getByTestId("channel-general").click();
38+
await expect(page.getByTestId("message-timeline")).toBeVisible();
39+
40+
await expect(
41+
dispatchWheelPrevented(page, '[data-testid="app-top-chrome"]', -120),
42+
).resolves.toBe(true);
43+
await expect(
44+
dispatchWheelPrevented(page, '[data-testid="sidebar-pinned-header"]', -120),
45+
).resolves.toBe(true);
46+
await expect(
47+
dispatchWheelPrevented(
48+
page,
49+
'[data-testid="app-sidebar-scroll-anchor"]',
50+
-120,
51+
),
52+
).resolves.toBe(true);
53+
await expect(
54+
dispatchWheelPrevented(page, '[data-testid="chat-title"]', -120),
55+
).resolves.toBe(true);
56+
57+
await expect(
58+
dispatchWheelPrevented(page, '[data-testid="message-timeline"]', -120),
59+
).resolves.toBe(false);
60+
});

0 commit comments

Comments
 (0)