Skip to content
Closed
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
80 changes: 80 additions & 0 deletions desktop/src/app/navigation/historyNavigationShortcuts.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import test from "node:test";

import { matchHistoryNavigationShortcut } from "./historyNavigationShortcuts.ts";

function probe(overrides) {
return {
key: "",
code: "",
metaKey: false,
ctrlKey: false,
altKey: false,
shiftKey: false,
...overrides,
};
}

test("mac back chord matches bracket key and code", () => {
assert.equal(
matchHistoryNavigationShortcut(
probe({ key: "[", code: "BracketLeft", metaKey: true }),
true,
),
"back",
);
assert.equal(
matchHistoryNavigationShortcut(
probe({ key: "Dead", code: "BracketLeft", metaKey: true }),
true,
),
"back",
);
});

test("mac forward chord matches bracket key and code", () => {
assert.equal(
matchHistoryNavigationShortcut(
probe({ key: "]", code: "BracketRight", metaKey: true }),
true,
),
"forward",
);
});

test("windows back and forward use alt arrows without modifiers", () => {
assert.equal(
matchHistoryNavigationShortcut(
probe({ key: "ArrowLeft", altKey: true }),
false,
),
"back",
);
assert.equal(
matchHistoryNavigationShortcut(
probe({ key: "ArrowRight", altKey: true }),
false,
),
"forward",
);
});

test("shift modifier blocks history chords", () => {
assert.equal(
matchHistoryNavigationShortcut(
probe({ key: "ArrowLeft", altKey: true, shiftKey: true }),
false,
),
null,
);
});

test("ctrl arrow left is not history navigation on windows", () => {
assert.equal(
matchHistoryNavigationShortcut(
probe({ key: "ArrowLeft", ctrlKey: true }),
false,
),
null,
);
});
47 changes: 47 additions & 0 deletions desktop/src/app/navigation/historyNavigationShortcuts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
export type HistoryNavigationDirection = "back" | "forward";

export type HistoryShortcutProbe = {
key: string;
code: string;
metaKey: boolean;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
};

/**
* Match global back/forward history chords. These intentionally bypass the
* editable-target guard — they have no text-editing semantics in composers.
*/
export function matchHistoryNavigationShortcut(
probe: HistoryShortcutProbe,
isMac: boolean,
): HistoryNavigationDirection | null {
if (probe.shiftKey) {
return null;
}

if (isMac) {
if (!probe.metaKey || probe.ctrlKey || probe.altKey) {
return null;
}
if (probe.key === "[" || probe.code === "BracketLeft") {
return "back";
}
if (probe.key === "]" || probe.code === "BracketRight") {
return "forward";
}
return null;
}

if (!probe.altKey || probe.metaKey || probe.ctrlKey) {
return null;
}
if (probe.key === "ArrowLeft") {
return "back";
}
if (probe.key === "ArrowRight") {
return "forward";
}
return null;
}
58 changes: 15 additions & 43 deletions desktop/src/app/navigation/useBackForwardControls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
useRouterState,
} from "@tanstack/react-router";

import { matchHistoryNavigationShortcut } from "@/app/navigation/historyNavigationShortcuts";
import { isMacPlatform } from "@/shared/lib/platform";
import { trimMapToSize } from "@/shared/lib/trimMapToSize";

Expand All @@ -14,19 +15,6 @@ type RouterHistoryState = {
key?: string;
};

function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}

return (
target.isContentEditable ||
target.closest(
'input, textarea, select, [contenteditable=""], [contenteditable="true"]',
) !== null
);
}

export function useBackForwardControls() {
const router = useRouter();
const canGoBack = useCanGoBack();
Expand Down Expand Up @@ -81,41 +69,25 @@ export function useBackForwardControls() {
}, [canGoForward, router.history]);

const handleKeyDown = React.useEffectEvent((event: KeyboardEvent) => {
if (isEditableTarget(event.target)) {
return;
}

const isMac = isMacPlatform();
const isBackShortcut = isMac
? event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
!event.shiftKey &&
(event.key === "[" || event.code === "BracketLeft")
: event.altKey &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
event.key === "ArrowLeft";
const isForwardShortcut = isMac
? event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
!event.shiftKey &&
(event.key === "]" || event.code === "BracketRight")
: event.altKey &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
event.key === "ArrowRight";

if (isBackShortcut) {
const direction = matchHistoryNavigationShortcut(
{
key: event.key,
code: event.code,
metaKey: event.metaKey,
ctrlKey: event.ctrlKey,
altKey: event.altKey,
shiftKey: event.shiftKey,
},
isMacPlatform(),
);

if (direction === "back") {
event.preventDefault();
goBack();
return;
}

if (isForwardShortcut) {
if (direction === "forward") {
event.preventDefault();
goForward();
}
Expand Down
25 changes: 25 additions & 0 deletions desktop/tests/e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,31 @@ test("global back and forward move across channel routes", async ({ page }) => {
await expect(page.getByTestId("chat-title")).toHaveText("random");
});

test("back and forward keyboard chords work while the composer is focused", async ({
page,
}) => {
await page.goto("/");

await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");

await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");

const composer = page.getByTestId("message-composer");
await composer.click();
await expect(composer).toBeFocused();

await page.keyboard.press(process.platform === "darwin" ? "Meta+[" : "Alt+ArrowLeft");
await expect(page.getByTestId("chat-title")).toHaveText("general");

await composer.click();
await page.keyboard.press(
process.platform === "darwin" ? "Meta+]" : "Alt+ArrowRight",
);
await expect(page.getByTestId("chat-title")).toHaveText("random");
});

// FIXME: the forum post "Back to posts" header renders under the fixed top
// chrome drag region, which intercepts the click. Pre-existing breakage —
// this spec file was never registered in playwright.config.ts until now.
Expand Down