Skip to content
Open
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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export default defineConfig({
"**/reminders.spec.ts",
"**/virtualization.spec.ts",
"**/scroll-history.spec.ts",
"**/overscroll-boundary.spec.ts",
],
use: {
...devices["Desktop Chrome"],
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -87,6 +88,7 @@ const LazySettingsScreen = React.lazy(async () => {
export function AppShell() {
useWebviewZoomShortcuts();
useTauriWindowDrag();
useWebviewScrollBoundaryLock();

const workspacesHook = useWorkspaces();
const [isAddWorkspaceOpen, setIsAddWorkspaceOpen] = React.useState(false);
Expand Down
1 change: 1 addition & 0 deletions desktop/src/features/messages/ui/MessageThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,7 @@ export function MessageThreadPanel({
isSplitLayout && auxiliaryPanelContentPaddingClass,
!isSplitLayout && !isFloatingOverlay && "pt-[3.25rem]",
)}
data-buzz-conversation-scroll
data-testid="message-thread-body"
onScroll={onScroll}
ref={threadBodyRef}
Expand Down
3 changes: 2 additions & 1 deletion desktop/src/features/messages/ui/MessageTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -523,9 +523,10 @@ const MessageTimelineBase = React.forwardRef<
) : null}
<div
className={cn(
"absolute inset-0 overflow-y-auto overflow-x-hidden overscroll-none px-2 pt-1 [overflow-anchor:none]",
"absolute inset-0 overflow-y-auto overflow-x-hidden overscroll-contain px-2 pt-1 [overflow-anchor:none]",
hasComposerOverlay ? "pb-24" : "pb-4",
)}
data-buzz-conversation-scroll
data-scroll-restoration-id={scrollRestorationId}
data-testid="message-timeline"
key={scrollContainerDomKey}
Expand Down
95 changes: 95 additions & 0 deletions desktop/src/shared/hooks/useWebviewScrollBoundaryLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import * as React from "react";

const BOUNDARY_EPSILON_PX = 1;
const CONVERSATION_SCROLL_SELECTOR = "[data-buzz-conversation-scroll]";
const SCROLLABLE_OVERFLOW_VALUES = new Set(["auto", "scroll", "overlay"]);

function isHTMLElement(value: EventTarget | null): value is HTMLElement {
return value instanceof HTMLElement;
}

function isDocumentElement(element: HTMLElement) {
return element === document.body || element === document.documentElement;
}

function isScrollableY(element: HTMLElement) {
if (isDocumentElement(element)) {
return false;
}

const style = window.getComputedStyle(element);
if (!SCROLLABLE_OVERFLOW_VALUES.has(style.overflowY)) {
return false;
}

return element.scrollHeight > 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 });
};
}, []);
}
60 changes: 60 additions & 0 deletions desktop/tests/e2e/overscroll-boundary.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading