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
39 changes: 22 additions & 17 deletions apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,29 +23,34 @@ const handlers = new Map<HardwareKeyboardCommand, Set<CommandHandler>>();
const registrationListeners = new Set<() => void>();
let registrationVersion = 0;

export function useHardwareKeyboardCommand(
export function registerHardwareKeyboardCommand(
command: HardwareKeyboardCommand | ReadonlyArray<HardwareKeyboardCommand>,
handler: CommandHandler,
): void {
useEffect(() => {
const commands = typeof command === "string" ? [command] : command;
): () => void {
const commands = typeof command === "string" ? [command] : command;
for (const command of commands) {
const commandHandlers = handlers.get(command) ?? new Set<CommandHandler>();
commandHandlers.add(handler);
handlers.set(command, commandHandlers);
}
registrationVersion += 1;
registrationListeners.forEach((listener) => listener());
return () => {
for (const command of commands) {
const commandHandlers = handlers.get(command) ?? new Set<CommandHandler>();
commandHandlers.add(handler);
handlers.set(command, commandHandlers);
const commandHandlers = handlers.get(command);
commandHandlers?.delete(handler);
if (commandHandlers?.size === 0) handlers.delete(command);
}
registrationVersion += 1;
registrationListeners.forEach((listener) => listener());
return () => {
for (const command of commands) {
const commandHandlers = handlers.get(command);
commandHandlers?.delete(handler);
if (commandHandlers?.size === 0) handlers.delete(command);
}
registrationVersion += 1;
registrationListeners.forEach((listener) => listener());
};
}, [command, handler]);
};
}

export function useHardwareKeyboardCommand(
command: HardwareKeyboardCommand | ReadonlyArray<HardwareKeyboardCommand>,
handler: CommandHandler,
): void {
useEffect(() => registerHardwareKeyboardCommand(command, handler), [command, handler]);
}

export function getRegisteredHardwareKeyboardCommands(): ReadonlySet<HardwareKeyboardCommand> {
Expand Down
62 changes: 62 additions & 0 deletions apps/mobile/src/features/keyboard/threadKeyboardShortcuts.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";

import type { ThreadListV2ListItem } from "../threads/threadListV2";
import {
dispatchHardwareKeyboardCommand,
getHardwareKeyboardCommandRegistrationVersion,
} from "./hardwareKeyboardCommands";
import { useThreadJumpShortcuts } from "./threadKeyboardShortcuts";

function threadItem(id: string): ThreadListV2ListItem {
const thread = { environmentId: "environment-1", id } as unknown as EnvironmentThreadShell;
return {
type: "v2-thread",
key: id,
item: { thread, variant: "card", snoozed: false, pinned: false, isLast: false, wokeAt: null },
snoozeWakeLabelText: undefined,
timeLabel: "",
snoozePresetMinute: undefined,
showTrailingDivider: false,
hasQueuedMessages: false,
canMoveUp: false,
canMoveDown: false,
};
}

function Probe(props: {
readonly items: ReadonlyArray<ThreadListV2ListItem>;
readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
}) {
useThreadJumpShortcuts(props.items, props.onSelectThread);
return null;
}

let root: Root | null = null;

afterEach(() => {
act(() => root?.unmount());
root = null;
});

describe("useThreadJumpShortcuts", () => {
it("registers once and jumps within the latest list", () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
root = createRoot(document.createElement("div"));
act(() => root!.render(<Probe items={[threadItem("first")]} onSelectThread={vi.fn()} />));
const registrationVersion = getHardwareKeyboardCommandRegistrationVersion();

const onSelectThread = vi.fn();
for (const id of ["second", "third"]) {
act(() => root!.render(<Probe items={[threadItem(id)]} onSelectThread={onSelectThread} />));
}

expect(getHardwareKeyboardCommandRegistrationVersion()).toBe(registrationVersion);
expect(dispatchHardwareKeyboardCommand("thread.jump.1")).toBe(true);
expect(onSelectThread).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ id: "third" }),
);
});
});
23 changes: 13 additions & 10 deletions apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts";
import { useCallback } from "react";
import { useEffect, useEffectEvent } from "react";

import type { ThreadListV2ListItem } from "../threads/threadListV2";
import {
useHardwareKeyboardCommand,
registerHardwareKeyboardCommand,
type HardwareKeyboardCommand,
} from "./hardwareKeyboardCommands";

Expand All @@ -31,13 +31,16 @@ export function useThreadJumpShortcuts(
items: ReadonlyArray<ThreadShortcutListItem>,
onSelectThread: (thread: EnvironmentThreadShell) => void,
) {
const jumpToThread = useCallback(
(command: HardwareKeyboardCommand) => {
const thread = threadJumpTarget(items, command);
if (thread !== null) onSelectThread(thread);
return true;
},
[items, onSelectThread],
const jumpToThread = useEffectEvent((command: HardwareKeyboardCommand) => {
const thread = threadJumpTarget(items, command);
if (thread !== null) onSelectThread(thread);
});
useEffect(
() =>
registerHardwareKeyboardCommand(THREAD_JUMP_KEYBINDING_COMMANDS, (command) => {
jumpToThread(command);
return true;
}),
[],
);
useHardwareKeyboardCommand(THREAD_JUMP_KEYBINDING_COMMANDS, jumpToThread);
}
Loading
Loading