diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 404200b30..0c827cc95 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -55,6 +55,7 @@ export default defineConfig({ "**/reminders.spec.ts", "**/virtualization.spec.ts", "**/scroll-history.spec.ts", + "**/overscroll-boundary.spec.ts", "**/cold-switch-longtask.perf.ts", "**/timeline-no-shift.spec.ts", ], diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 8c1a21a32..0d6eca5e4 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -68,6 +68,7 @@ import { relayClient } from "@/shared/api/relayClient"; import { useIdentityQuery } from "@/shared/api/hooks"; import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal"; import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup"; +import { useWebviewScrollBoundaryLock } from "@/shared/hooks/useWebviewScrollBoundaryLock"; import { joinChannel } from "@/shared/api/tauri"; import type { SearchHit } from "@/shared/api/types"; import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext"; @@ -87,6 +88,7 @@ const LazySettingsScreen = React.lazy(async () => { export function AppShell() { useWebviewZoomShortcuts(); useTauriWindowDrag(); + useWebviewScrollBoundaryLock(); const workspacesHook = useWorkspaces(); const [isAddWorkspaceOpen, setIsAddWorkspaceOpen] = React.useState(false); diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 7060694ea..d5539d406 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -561,6 +561,7 @@ export function MessageThreadPanel({ const threadScrollRegion = ( element.clientHeight + BOUNDARY_EPSILON_PX; +} + +function canScrollY(element: HTMLElement, deltaY: number) { + if (deltaY < 0) { + return element.scrollTop > BOUNDARY_EPSILON_PX; + } + + const maxScrollTop = element.scrollHeight - element.clientHeight; + return element.scrollTop < maxScrollTop - BOUNDARY_EPSILON_PX; +} + +function isConversationScroller(element: HTMLElement) { + return Boolean(element.closest(CONVERSATION_SCROLL_SELECTOR)); +} + +/** + * Stops macOS/WKWebView rubber-band gestures from escaping into the viewport. + * + * Buzz is laid out as fixed-height nested panes. On macOS, a wheel/trackpad + * gesture that starts over a non-scrollable pane (or over a scrollable pane at + * its boundary) can still be handed to the WKWebView viewport, which rubber- + * bands the entire app and reveals a blank strip above/below the UI. CSS + * `overscroll-behavior` is not enough for all of the empty/header/footer hit + * targets in the webview, so this capture listener consumes only gestures that + * otherwise have nowhere app-local to scroll. + * + * Real scrolling is left alone: if any scroll container under the pointer can + * move in the wheel direction, the browser handles it normally. At boundaries, + * only containers marked with `data-buzz-conversation-scroll` are allowed to + * receive the gesture so their own local elastic affordance can remain; every + * other boundary is locked and cannot chain to the viewport. + */ +export function useWebviewScrollBoundaryLock() { + React.useEffect(() => { + function handleWheel(event: WheelEvent) { + if (event.defaultPrevented || event.deltaY === 0 || event.ctrlKey) { + return; + } + + const path = event.composedPath(); + let firstScrollable: HTMLElement | null = null; + + for (const target of path) { + if (!isHTMLElement(target) || !isScrollableY(target)) { + continue; + } + + firstScrollable ??= target; + if (canScrollY(target, event.deltaY)) { + return; + } + } + + if (firstScrollable && isConversationScroller(firstScrollable)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + } + + window.addEventListener("wheel", handleWheel, { + capture: true, + passive: false, + }); + return () => { + window.removeEventListener("wheel", handleWheel, { capture: true }); + }; + }, []); +} diff --git a/desktop/tests/e2e/overscroll-boundary.spec.ts b/desktop/tests/e2e/overscroll-boundary.spec.ts new file mode 100644 index 000000000..faeebf3f9 --- /dev/null +++ b/desktop/tests/e2e/overscroll-boundary.spec.ts @@ -0,0 +1,60 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +async function dispatchWheelPrevented( + page: import("@playwright/test").Page, + selector: string, + deltaY: number, +) { + return page.evaluate( + ({ selector, deltaY }) => { + const element = document.querySelector(selector); + if (!element) { + throw new Error(`Missing element for selector: ${selector}`); + } + + const event = new WheelEvent("wheel", { + bubbles: true, + cancelable: true, + deltaY, + }); + element.dispatchEvent(event); + return event.defaultPrevented; + }, + { selector, deltaY }, + ); +} + +test.beforeEach(async ({ page }) => { + await installMockBridge(page); +}); + +test("locks viewport rubber-band outside conversation scrollers", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("message-timeline")).toBeVisible(); + + await expect( + dispatchWheelPrevented(page, '[data-testid="app-top-chrome"]', -120), + ).resolves.toBe(true); + await expect( + dispatchWheelPrevented(page, '[data-testid="sidebar-pinned-header"]', -120), + ).resolves.toBe(true); + await expect( + dispatchWheelPrevented( + page, + '[data-testid="app-sidebar-scroll-anchor"]', + -120, + ), + ).resolves.toBe(true); + await expect( + dispatchWheelPrevented(page, '[data-testid="chat-title"]', -120), + ).resolves.toBe(true); + + await expect( + dispatchWheelPrevented(page, '[data-testid="message-timeline"]', -120), + ).resolves.toBe(false); +});