diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index c1ea0e061b9..b16008e3654 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -114,6 +114,7 @@ export default defineConfig({ "**/inbox-refactor-screenshots.spec.ts", "**/buzz-theme-screenshots.spec.ts", "**/channel-sort.spec.ts", + "**/sidebar-drag-activation.spec.ts", "**/identity-lost.spec.ts", "**/deep-link-invite.spec.ts", "**/invite-link-copy.spec.ts", diff --git a/desktop/src/features/sidebar/lib/pressAwarePointerSensor.ts b/desktop/src/features/sidebar/lib/pressAwarePointerSensor.ts new file mode 100644 index 00000000000..2df4911a1a3 --- /dev/null +++ b/desktop/src/features/sidebar/lib/pressAwarePointerSensor.ts @@ -0,0 +1,43 @@ +import { PointerSensor } from "@dnd-kit/core"; +import type { PointerSensorOptions } from "@dnd-kit/core"; +import type { PointerEvent as ReactPointerEvent } from "react"; + +/** + * A {@link PointerSensor} that refuses a `pointerdown` reporting no button + * held, instead of arming a drag it will never see released. + * + * dnd-kit starts a drag from pointer travel alone and never re-reads the button + * state, so a `pointerup` the page does not receive leaves the sensor armed: + * the next cursor move begins a drag nobody asked for, and the click that move + * belonged to is swallowed, because dnd-kit installs a capture-phase `click` + * blocker as soon as a drag activates. + * + * The macOS webview does exactly that with tap-to-click. A tap is recognised + * after the finger has already lifted, so `pointerdown` arrives with + * `buttons: 0` and the matching `pointerup` is deferred — measured at 356ms to + * over 1.2s, and sometimes not before the next input at all. A deliberate + * press-and-hold reports `buttons: 1` throughout, which is what makes the two + * separable at the one moment that decides everything: over 38 measured + * gestures, every `buttons: 0` press was a click and every `buttons: 1` press + * was a real drag, with nothing in between. + * + * So the fix is to not start. dnd-kit only instantiates a sensor when the + * activator returns exactly `true` (`@dnd-kit/core` 6.3.1, + * `bindActivatorToSensorInstantiator`), and reads `activators` off the sensor + * class, so declining here means no session, no click blocker, and nothing to + * unwind. The base handler is delegated to for everything else — it is what + * rejects non-primary and non-left presses, and what fires `onActivation`. + */ +export class PressAwarePointerSensor extends PointerSensor { + static activators = [ + { + eventName: "onPointerDown" as const, + handler: ( + event: ReactPointerEvent, + options: PointerSensorOptions, + ): boolean => + event.nativeEvent.buttons !== 0 && + PointerSensor.activators[0].handler(event, options), + }, + ]; +} diff --git a/desktop/src/features/sidebar/ui/CommunityRail.tsx b/desktop/src/features/sidebar/ui/CommunityRail.tsx index 5394065b19c..a93ef7ada94 100644 --- a/desktop/src/features/sidebar/ui/CommunityRail.tsx +++ b/desktop/src/features/sidebar/ui/CommunityRail.tsx @@ -2,7 +2,6 @@ import { DndContext, DragOverlay, KeyboardSensor, - PointerSensor, useSensor, useSensors, } from "@dnd-kit/core"; @@ -35,6 +34,7 @@ import { ContextMenuTrigger, } from "@/shared/ui/context-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { PressAwarePointerSensor } from "@/features/sidebar/lib/pressAwarePointerSensor"; import { cn } from "@/shared/lib/cn"; import { getInitials } from "@/shared/lib/initials"; import { writeTextToClipboard } from "@/shared/lib/clipboard"; @@ -328,7 +328,9 @@ export function CommunityRail({ const [draggingId, setDraggingId] = React.useState(null); const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(PressAwarePointerSensor, { + activationConstraint: { distance: 6 }, + }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), diff --git a/desktop/src/features/sidebar/ui/SidebarDnd.tsx b/desktop/src/features/sidebar/ui/SidebarDnd.tsx index 41cc6fe669b..63fe667393e 100644 --- a/desktop/src/features/sidebar/ui/SidebarDnd.tsx +++ b/desktop/src/features/sidebar/ui/SidebarDnd.tsx @@ -2,7 +2,6 @@ import { DndContext, DragOverlay, - PointerSensor, pointerWithin, useDraggable, useDroppable, @@ -20,6 +19,7 @@ import { CSS } from "@dnd-kit/utilities"; import { Hash } from "lucide-react"; import * as React from "react"; +import { PressAwarePointerSensor } from "@/features/sidebar/lib/pressAwarePointerSensor"; import { cn } from "@/shared/lib/cn"; export type DndChannelData = { type: "channel"; channelId: string }; @@ -192,7 +192,9 @@ export function SidebarDndContext({ const [activeDragItem, setActiveDragItem] = React.useState(null); const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(PressAwarePointerSensor, { + activationConstraint: { distance: 6 }, + }), ); const handleDragStart = React.useCallback( diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts index 0a4c8830e12..72292685ea0 100644 --- a/desktop/tests/e2e/community-rail.spec.ts +++ b/desktop/tests/e2e/community-rail.spec.ts @@ -1085,6 +1085,50 @@ test.describe("community rail", () => { expect(reloadBoxB.y).toBeLessThan(reloadBoxA.y); }); + test("a press reporting no button held does not arm a rail drag or eat the next click", async ({ + page, + }) => { + await installMockBridge(page, undefined, { skipCommunitySeed: true }); + await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); + await page.goto("/"); + + const buttonA = page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`); + const buttonB = page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`); + // B must not already be the active community, or the assertion at the end + // would hold with the click never landing. + await expect(buttonA).toHaveAttribute("aria-current", "true"); + await expect(buttonB).not.toHaveAttribute("aria-current", "true"); + + const boxA = await buttonA.boundingBox(); + const boxB = await buttonB.boundingBox(); + if (!boxA || !boxB) throw new Error("community buttons not laid out"); + + // The press the macOS webview reports for tap-to-click: no button held, and + // the release deferred past the next input. page.mouse.down() always + // reports a held button, so it has to be dispatched directly. + await buttonA.dispatchEvent("pointerdown", { + button: 0, + buttons: 0, + isPrimary: true, + pointerId: 1, + pointerType: "mouse", + clientX: boxA.x + boxA.width / 2, + clientY: boxA.y + boxA.height / 2, + }); + await page.mouse.move(boxA.x + boxA.width / 2, boxA.y + boxA.height / 2); + + // Travelling to the other community is well past the 6px activation + // distance; a drag armed here would swallow the click that follows and the + // community switch would silently fail. + await page.mouse.move(boxB.x + boxB.width / 2, boxB.y + boxB.height / 2, { + steps: 10, + }); + await page.mouse.down(); + await page.mouse.up(); + + await expect(buttonB).toHaveAttribute("aria-current", "true"); + }); + test("keyboard reorder: Space to pick up, ArrowUp to move, Space to drop updates stored order", async ({ page, }) => { diff --git a/desktop/tests/e2e/sidebar-drag-activation.spec.ts b/desktop/tests/e2e/sidebar-drag-activation.spec.ts new file mode 100644 index 00000000000..1f74a874a35 --- /dev/null +++ b/desktop/tests/e2e/sidebar-drag-activation.spec.ts @@ -0,0 +1,146 @@ +import { expect, test } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +// Mock-mode current-user pubkey (DEFAULT_MOCK_IDENTITY); custom channel +// sections persist under buzz-channel-sections.v1:. +const MOCK_PUBKEY = "deadbeef".repeat(8); +const SECTIONS_KEY = `buzz-channel-sections.v1:${MOCK_PUBKEY}`; +const SECTION_TOP = { id: "sec-top", name: "Priority", order: 0 }; +const SECTION_BOTTOM = { id: "sec-bottom", name: "Archive", order: 1 }; + +async function seedChannelSections(page: Page) { + await page.addInitScript( + ({ key, sections }) => { + window.localStorage.setItem( + key, + JSON.stringify({ version: 1, sections, assignments: {} }), + ); + }, + { key: SECTIONS_KEY, sections: [SECTION_TOP, SECTION_BOTTOM] }, + ); +} + +// dnd-kit marks each section's wrapping row with aria-roledescription +// ="sortable" and spreads the drag listeners there, so the row is the handle. +function sectionRows(page: Page) { + return page.locator('[aria-roledescription="sortable"]'); +} + +// Returns each row's own section name, so an unexpected row shows up as its +// text instead of being collapsed into a catch-all bucket. +async function sectionOrder(page: Page) { + return sectionRows(page).evaluateAll( + (rows, names) => + rows.map((row) => { + const text = row.textContent?.trim() ?? ""; + return names.find((name) => text.startsWith(name)) ?? text; + }), + [SECTION_TOP.name, SECTION_BOTTOM.name], + ); +} + +/** + * The press the macOS webview reports for tap-to-click: a `pointerdown` with no + * button held, whose `pointerup` arrives hundreds of milliseconds later or not + * before the next input at all. `page.mouse.down()` cannot produce it — it + * always reports a held button — so it is dispatched directly, with real + * viewport coordinates so dnd-kit measures activation distance from the row + * rather than from the origin. + */ +async function pressWithNoButtonHeld(target: Locator) { + const box = await target.boundingBox(); + if (!box) throw new Error("press target is not laid out"); + const clientX = box.x + box.width / 2; + const clientY = box.y + box.height / 2; + await target.dispatchEvent("pointerdown", { + button: 0, + buttons: 0, + isPrimary: true, + pointerId: 1, + pointerType: "mouse", + clientX, + clientY, + }); + return { clientX, clientY }; +} + +test("a press reporting no button held does not arm a drag or eat the next click", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + + const random = page.getByTestId("channel-random"); + const general = page.getByTestId("channel-general"); + await expect(random).toBeVisible(); + // The channel the click has to reach must not already be the selected one, or + // the assertion at the end would hold with the click never landing. + await expect(general).not.toHaveAttribute("data-active", "true"); + const target = await general.boundingBox(); + if (!target) throw new Error("channel rows are not laid out"); + + const { clientX, clientY } = await pressWithNoButtonHeld(random); + await page.mouse.move(clientX, clientY); + + // Travel far past the 6px activation distance with no release in between: + // this is the move that used to start a drag out of a finished click. + await page.mouse.move( + target.x + target.width / 2, + target.y + target.height / 2, + { steps: 10 }, + ); + + // An ordinary click, which dnd-kit swallows from its capture-phase blocker + // whenever a drag is live. + await page.mouse.down(); + await page.mouse.up(); + + await expect(general).toHaveAttribute("data-active", "true"); +}); + +test("a real press still drags right after one was refused", async ({ + page, +}) => { + await seedChannelSections(page); + await installMockBridge(page); + await page.goto("/"); + + const rows = sectionRows(page); + await expect(rows).toHaveCount(2); + const top = rows.filter({ hasText: SECTION_TOP.name }); + const bottom = rows.filter({ hasText: SECTION_BOTTOM.name }); + expect(await sectionOrder(page)).toEqual([ + SECTION_TOP.name, + SECTION_BOTTOM.name, + ]); + + const from = await top.boundingBox(); + const to = await bottom.boundingBox(); + if (!from || !to) throw new Error("section rows are not laid out"); + const toX = to.x + to.width / 2; + const toY = to.y + to.height / 2; + + // Refuse a press, then drag for real from the same row. Nothing may be left + // armed or half-torn-down by the refusal. + const { clientX, clientY } = await pressWithNoButtonHeld(top); + await page.mouse.move(clientX, clientY); + await page.mouse.move(toX, toY, { steps: 10 }); + expect(await sectionOrder(page)).toEqual([ + SECTION_TOP.name, + SECTION_BOTTOM.name, + ]); + + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2); + await page.mouse.down(); + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2 + 14); + await page.mouse.move(toX, toY, { steps: 10 }); + await page.mouse.up(); + + // The drop committed, so the order flipped: refusing button-free presses + // costs nothing a deliberate drag needs. + await expect + .poll(() => sectionOrder(page)) + .toEqual([SECTION_BOTTOM.name, SECTION_TOP.name]); +});