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 @@ -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",
Expand Down
43 changes: 43 additions & 0 deletions desktop/src/features/sidebar/lib/pressAwarePointerSensor.ts
Original file line number Diff line number Diff line change
@@ -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),
},
];
}
6 changes: 4 additions & 2 deletions desktop/src/features/sidebar/ui/CommunityRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -328,7 +328,9 @@ export function CommunityRail({
const [draggingId, setDraggingId] = React.useState<string | null>(null);

const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(PressAwarePointerSensor, {
activationConstraint: { distance: 6 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
Expand Down
6 changes: 4 additions & 2 deletions desktop/src/features/sidebar/ui/SidebarDnd.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import {
DndContext,
DragOverlay,
PointerSensor,
pointerWithin,
useDraggable,
useDroppable,
Expand All @@ -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 };
Expand Down Expand Up @@ -192,7 +192,9 @@ export function SidebarDndContext({
const [activeDragItem, setActiveDragItem] =
React.useState<SidebarDragItem | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(PressAwarePointerSensor, {
activationConstraint: { distance: 6 },
}),
);

const handleDragStart = React.useCallback(
Expand Down
44 changes: 44 additions & 0 deletions desktop/tests/e2e/community-rail.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}) => {
Expand Down
146 changes: 146 additions & 0 deletions desktop/tests/e2e/sidebar-drag-activation.spec.ts
Original file line number Diff line number Diff line change
@@ -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:<pubkey>.
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]);
});