Skip to content
Merged
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
28 changes: 26 additions & 2 deletions apps/web/src/speech/useDictationShortcut.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { expect, it, vi } from "vite-plus/test";
import { render } from "../../test/render";
import { useDictationShortcut, type DictationKeyHandlers } from "./useDictationShortcut";

vi.mock("../hooks/useSettings", () => ({ useClientSettings: () => "hold" }));
const settings = vi.hoisted(() => ({ mode: "hold" as "hold" | "auto" | "toggle" }));
vi.mock("../hooks/useSettings", () => ({ useClientSettings: () => settings.mode }));

type ShortcutInput = Parameters<typeof useDictationShortcut>[0];

Expand Down Expand Up @@ -42,7 +43,8 @@ const press = (repeat = false) =>
}),
);

function actions(available: boolean) {
function actions(available: boolean, mode: typeof settings.mode = "hold") {
settings.mode = mode;
return {
available,
state: { phase: "idle" as const, error: null, errorAction: null },
Expand Down Expand Up @@ -94,6 +96,28 @@ it("finishes a released hold after startup even if the chord was pressed again w
expect(speech.stop).toHaveBeenCalledOnce();
});

it.each([
{ mode: "hold", heldMs: 0, stops: 1 },
{ mode: "auto", heldMs: 300, stops: 1 },
{ mode: "auto", heldMs: 299, stops: 0 },
] as const)(
"ends a $mode press held $heldMs ms when a chord modifier is released without the letter keyup",
async ({ mode, heldMs, stops }) => {
const speech = actions(true, mode);
using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel");
await using _screen = await render(<Probe speech={speech} />);
using clock = vi.spyOn(performance, "now").mockReturnValue(0);
press();
clock.mockReturnValue(heldMs);
window.dispatchEvent(new KeyboardEvent("keyup", { key: "Alt", code: "AltLeft" }));
await speech.start.mock.results[0]?.value;
expect(speech.stop).not.toHaveBeenCalled();
window.dispatchEvent(new KeyboardEvent("keyup", { key: "Meta", code: "MetaLeft" }));
await speech.start.mock.results[0]?.value;
expect(speech.stop).toHaveBeenCalledTimes(stops);
},
);

const escape = () =>
new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true, cancelable: true });

Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/speech/useDictationShortcut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export function useDictationShortcut(input: ShortcutInput) {
const pressed = useRef<{
generation: number;
code: string;
modifiers: string[];
startedAt: number;
mode: typeof mode;
starting: Promise<void>;
Expand All @@ -110,7 +111,10 @@ export function useDictationShortcut(input: ShortcutInput) {
shortcutTargets.set(id.current, () => current.current);
const finish = (event: KeyboardEvent | null) => {
const press = pressed.current;
if (!press || (event && (event.code || event.key) !== press.code)) return;
if (!press) return;
// macOS can omit the letter keyup while Command is held; releasing a chord modifier ends it too.
if (event && (event.code || event.key) !== press.code && !press.modifiers.includes(event.key))
return;
pressed.current = null;
if (shouldFinishDictationOnRelease(press.mode, performance.now() - press.startedAt)) {
void press.starting.then(() => {
Expand Down Expand Up @@ -166,6 +170,7 @@ export function useDictationShortcut(input: ShortcutInput) {
pressed.current = {
generation: generation.current,
code: event.code || event.key,
modifiers: ["Alt", "Control", "Meta", "Shift"].filter((key) => event.getModifierState(key)),
startedAt: performance.now(),
mode,
starting: speech.start(),
Expand Down
Loading