hotkey: Convert module to typescript.

This commit is contained in:
Evy Kassirer
2025-09-19 11:42:32 -07:00
committed by Tim Abbott
parent 7c90d2cee5
commit ce56be02bc
8 changed files with 74 additions and 47 deletions

View File

@@ -121,7 +121,7 @@ EXEMPT_FILES = make_set(
"web/src/hash_util.ts", "web/src/hash_util.ts",
"web/src/hashchange.ts", "web/src/hashchange.ts",
"web/src/hbs.d.ts", "web/src/hbs.d.ts",
"web/src/hotkey.js", "web/src/hotkey.ts",
"web/src/inbox_ui.ts", "web/src/inbox_ui.ts",
"web/src/inbox_util.ts", "web/src/inbox_util.ts",
"web/src/info_overlay.ts", "web/src/info_overlay.ts",

View File

@@ -231,7 +231,7 @@ function get_quote_target(opts: {message_id?: number; quote_content?: string | u
} }
export function quote_message(opts: { export function quote_message(opts: {
message_id: number; message_id?: number;
quote_content?: string | undefined; quote_content?: string | undefined;
keep_composebox_empty?: boolean; keep_composebox_empty?: boolean;
reply_type?: "personal"; reply_type?: "personal";

View File

@@ -1,7 +1,7 @@
import $ from "jquery"; import $ from "jquery";
// Save the compose content cursor position and restore when we // Save the compose content cursor position and restore when we
// shift-tab back in (see hotkey.js). // shift-tab back in (see hotkey.ts).
let saved_compose_cursor = 0; let saved_compose_cursor = 0;
function set_compose_textarea_handlers(): void { function set_compose_textarea_handlers(): void {

View File

@@ -75,7 +75,7 @@ export function show_flatpickr(
assert(target !== undefined); assert(target !== undefined);
target.focus(); target.focus();
} else { } else {
// Prevent keypresses from propagating to our general hotkey.js // Prevent keypresses from propagating to our general hotkey.ts
// logic. Without this, `Up` will navigate both in the // logic. Without this, `Up` will navigate both in the
// flatpickr instance and in the message feed behind // flatpickr instance and in the message feed behind
// it. // it.

View File

@@ -65,8 +65,16 @@ import * as user_card_popover from "./user_card_popover.ts";
import * as user_group_popover from "./user_group_popover.ts"; import * as user_group_popover from "./user_group_popover.ts";
import {user_settings} from "./user_settings.ts"; import {user_settings} from "./user_settings.ts";
import * as user_topics_ui from "./user_topics_ui.ts"; import * as user_topics_ui from "./user_topics_ui.ts";
import * as util from "./util.ts";
function do_narrow_action(action) { function do_narrow_action(
action: (
target_id: number,
opts: {
trigger: string;
},
) => void,
): boolean {
if (message_lists.current === undefined) { if (message_lists.current === undefined) {
return false; return false;
} }
@@ -107,6 +115,11 @@ const KNOWN_IGNORE_SHIFT_MODIFIER_KEYS = new Set([
"@", "@",
]); ]);
type Hotkey = {
name: string;
message_view_only: boolean;
};
// Note that multiple keys can map to the same event_name, which // Note that multiple keys can map to the same event_name, which
// we'll do in cases where they have the exact same semantics. // we'll do in cases where they have the exact same semantics.
// DON'T FORGET: update keyboard_shortcuts.html // DON'T FORGET: update keyboard_shortcuts.html
@@ -117,7 +130,7 @@ const KNOWN_IGNORE_SHIFT_MODIFIER_KEYS = new Set([
// in the main message view with a selected message. // in the main message view with a selected message.
// `message_view_only` hotkeys, as a group, are not processed if any // `message_view_only` hotkeys, as a group, are not processed if any
// overlays are open (e.g. settings, streams, etc.). // overlays are open (e.g. settings, streams, etc.).
const KEYDOWN_MAPPINGS = { const KEYDOWN_MAPPINGS: Record<string, Hotkey | Hotkey[]> = {
"Alt+P": {name: "toggle_compose_preview", message_view_only: true}, "Alt+P": {name: "toggle_compose_preview", message_view_only: true},
"Ctrl+[": {name: "escape", message_view_only: false}, "Ctrl+[": {name: "escape", message_view_only: false},
"Cmd+Enter": {name: "action_with_enter", message_view_only: true}, "Cmd+Enter": {name: "action_with_enter", message_view_only: true},
@@ -239,7 +252,7 @@ const KNOWN_NAMED_KEY_ATTRIBUTE_VALUES = new Set([
"Tab", "Tab",
]); ]);
const CODE_TO_QWERTY_CHAR = { const CODE_TO_QWERTY_CHAR: Record<string, string> = {
KeyA: "a", KeyA: "a",
KeyB: "b", KeyB: "b",
KeyC: "c", KeyC: "c",
@@ -310,7 +323,7 @@ const CODE_TO_QWERTY_CHAR = {
// Keyboard Event Viewer tool: // Keyboard Event Viewer tool:
// https://w3c.github.io/uievents/tools/key-event-viewer.html // https://w3c.github.io/uievents/tools/key-event-viewer.html
export function get_keydown_hotkey(e) { export function get_keydown_hotkey(e: JQuery.KeyDownEvent): Hotkey | Hotkey[] | undefined {
// Determine the key pressed in a consistent way. // Determine the key pressed in a consistent way.
// //
// For keyboard layouts (e.g. QWERTY) where `e.key` results // For keyboard layouts (e.g. QWERTY) where `e.key` results
@@ -327,7 +340,11 @@ export function get_keydown_hotkey(e) {
let key = e.key; let key = e.key;
if (!use_event_key) { if (!use_event_key) {
const code = `${e.shiftKey ? "Shift+" : ""}${e.code}`; const code = `${e.shiftKey ? "Shift+" : ""}${e.code}`;
key = CODE_TO_QWERTY_CHAR[code]; if (CODE_TO_QWERTY_CHAR[code]) {
key = CODE_TO_QWERTY_CHAR[code];
} else {
return undefined;
}
} }
if (common.has_mac_keyboard() && e.ctrlKey && key !== "[") { if (common.has_mac_keyboard() && e.ctrlKey && key !== "[") {
@@ -362,7 +379,7 @@ export function get_keydown_hotkey(e) {
return KEYDOWN_MAPPINGS[key]; return KEYDOWN_MAPPINGS[key];
} }
export let processing_text = () => { export let processing_text = (): boolean => {
const $focused_elt = $(":focus"); const $focused_elt = $(":focus");
return ( return (
$focused_elt.is("input") || $focused_elt.is("input") ||
@@ -374,16 +391,13 @@ export let processing_text = () => {
); );
}; };
export function rewire_processing_text(value) { export function rewire_processing_text(value: typeof processing_text): void {
processing_text = value; processing_text = value;
} }
export function in_content_editable_widget(e) {
return $(e.target).is(".editable-section");
}
// Returns true if we handled it, false if the browser should. // Returns true if we handled it, false if the browser should.
export function process_escape_key(e) { function process_escape_key(e: JQuery.KeyDownEvent): boolean {
assert(e.target instanceof HTMLElement);
if ( if (
recent_view_ui.is_in_focus() && recent_view_ui.is_in_focus() &&
// This will return false if `e.target` is not // This will return false if `e.target` is not
@@ -514,7 +528,7 @@ export function process_escape_key(e) {
return false; return false;
} }
function handle_popover_events(event_name) { function handle_popover_events(event_name: string): boolean {
const popover_menu_visible_instance = popover_menus.get_visible_instance(); const popover_menu_visible_instance = popover_menus.get_visible_instance();
if (popover_menus.is_stream_actions_popover_displayed()) { if (popover_menus.is_stream_actions_popover_displayed()) {
@@ -570,7 +584,7 @@ function handle_popover_events(event_name) {
} }
// Returns true if we handled it, false if the browser should. // Returns true if we handled it, false if the browser should.
export function process_enter_key(e) { function process_enter_key(e: JQuery.KeyDownEvent): boolean {
if ($(e.target).hasClass("trigger-click-on-enter")) { if ($(e.target).hasClass("trigger-click-on-enter")) {
// If the target has the class "trigger-click-on-enter", explicitly // If the target has the class "trigger-click-on-enter", explicitly
// trigger a click event on it to call the associated click handler. // trigger a click event on it to call the associated click handler.
@@ -583,6 +597,7 @@ export function process_enter_key(e) {
// If a popover is open and we pressed Enter on a menu item, // If a popover is open and we pressed Enter on a menu item,
// call click directly on the item to navigate to the `href`. // call click directly on the item to navigate to the `href`.
// trigger("click") doesn't work for them to navigate to `href`. // trigger("click") doesn't work for them to navigate to `href`.
assert(e.target instanceof HTMLElement);
e.target.click(); e.target.click();
e.preventDefault(); e.preventDefault();
popovers.hide_all(); popovers.hide_all();
@@ -636,6 +651,7 @@ export function process_enter_key(e) {
// Transfer the enter keypress from button to the `<i>` tag inside // Transfer the enter keypress from button to the `<i>` tag inside
// it since it is the trigger for the popover. <button> is already used // it since it is the trigger for the popover. <button> is already used
// to trigger the tooltip so it cannot be used to trigger the popover. // to trigger the tooltip so it cannot be used to trigger the popover.
assert(e.target instanceof HTMLElement);
if (e.target.id === "send_later") { if (e.target.id === "send_later") {
compose_send_menu_popover.toggle(); compose_send_menu_popover.toggle();
return true; return true;
@@ -705,7 +721,7 @@ export function process_enter_key(e) {
return false; return false;
} }
window.location = hash_util.by_conversation_and_time_url(message); window.location.href = hash_util.by_conversation_and_time_url(message);
return true; return true;
} }
@@ -713,7 +729,7 @@ export function process_enter_key(e) {
return true; return true;
} }
export function process_cmd_or_ctrl_enter_key() { export function process_cmd_or_ctrl_enter_key(): boolean {
if ($("#compose").hasClass("preview_mode")) { if ($("#compose").hasClass("preview_mode")) {
const cmd_or_ctrl_pressed = true; const cmd_or_ctrl_pressed = true;
compose.handle_enter_key_with_preview_open(cmd_or_ctrl_pressed); compose.handle_enter_key_with_preview_open(cmd_or_ctrl_pressed);
@@ -723,7 +739,7 @@ export function process_cmd_or_ctrl_enter_key() {
return false; return false;
} }
export function process_tab_key() { export function process_tab_key(): boolean {
// Returns true if we handled it, false if the browser should. // Returns true if we handled it, false if the browser should.
// TODO: See if browsers like Safari can now handle tabbing correctly // TODO: See if browsers like Safari can now handle tabbing correctly
// without our intervention. // without our intervention.
@@ -752,7 +768,7 @@ export function process_tab_key() {
return false; return false;
} }
export function process_shift_tab_key() { export function process_shift_tab_key(): boolean {
// Returns true if we handled it, false if the browser should. // Returns true if we handled it, false if the browser should.
// TODO: See if browsers like Safari can now handle tabbing correctly // TODO: See if browsers like Safari can now handle tabbing correctly
// without our intervention. // without our intervention.
@@ -796,7 +812,7 @@ export function process_shift_tab_key() {
// Process a keydown event. // Process a keydown event.
// //
// Returns true if we handled it, false if the browser should. // Returns true if we handled it, false if the browser should.
export function process_hotkey(e, hotkey) { function process_hotkey(e: JQuery.KeyDownEvent, hotkey: Hotkey): boolean {
const event_name = hotkey.name; const event_name = hotkey.name;
// This block needs to be before the `Tab` handler. // This block needs to be before the `Tab` handler.
@@ -815,6 +831,7 @@ export function process_hotkey(e, hotkey) {
case "shift_tab": case "shift_tab":
case "open_recent_view": case "open_recent_view":
if (recent_view_ui.is_in_focus()) { if (recent_view_ui.is_in_focus()) {
assert(e.target instanceof HTMLElement);
return recent_view_ui.change_focused_element($(e.target), event_name); return recent_view_ui.change_focused_element($(e.target), event_name);
} }
} }
@@ -844,7 +861,7 @@ export function process_hotkey(e, hotkey) {
case "enter": case "enter":
return process_enter_key(e); return process_enter_key(e);
case "action_with_enter": case "action_with_enter":
return process_cmd_or_ctrl_enter_key(e); return process_cmd_or_ctrl_enter_key();
case "tab": case "tab":
return process_tab_key(); return process_tab_key();
case "shift_tab": case "shift_tab":
@@ -952,9 +969,11 @@ export function process_hotkey(e, hotkey) {
} }
if (event_name === "toggle_compose_preview") { if (event_name === "toggle_compose_preview") {
const $last_focused_compose_type_input = $( const last_focused_compose_type_input = compose_state.get_last_focused_compose_type_input();
compose_state.get_last_focused_compose_type_input(), if (last_focused_compose_type_input === undefined) {
); return false;
}
const $last_focused_compose_type_input = $(last_focused_compose_type_input);
if ($last_focused_compose_type_input.hasClass("message_edit_content")) { if ($last_focused_compose_type_input.hasClass("message_edit_content")) {
if ($last_focused_compose_type_input.closest(".preview_mode").length > 0) { if ($last_focused_compose_type_input.closest(".preview_mode").length > 0) {
@@ -1011,7 +1030,7 @@ export function process_hotkey(e, hotkey) {
if (event_name === "open_saved_snippet_dropdown") { if (event_name === "open_saved_snippet_dropdown") {
const $messagebox = $(":focus").parents(".messagebox"); const $messagebox = $(":focus").parents(".messagebox");
if ($messagebox.length === 1) { if ($messagebox.length === 1) {
$messagebox.find(".saved_snippets_widget")[0].click(); util.the($messagebox.find(".saved_snippets_widget")).click();
} }
} }
@@ -1029,7 +1048,7 @@ export function process_hotkey(e, hotkey) {
if (event_name === "up_arrow" && $(":focus").attr("id") === "search_query") { if (event_name === "up_arrow" && $(":focus").attr("id") === "search_query") {
$("#search_query").trigger("blur"); $("#search_query").trigger("blur");
message_scroll_state.set_keyboard_triggered_current_scroll(true); message_scroll_state.set_keyboard_triggered_current_scroll(true);
navigate.up(true); navigate.up();
} }
if ( if (
@@ -1047,7 +1066,7 @@ export function process_hotkey(e, hotkey) {
return true; return true;
case "page_down": { case "page_down": {
// so that it always goes to the end of the text box. // so that it always goes to the end of the text box.
const height = $(":focus")[0].scrollHeight; const height = util.the($(":focus")).scrollHeight;
$(":focus") $(":focus")
.caret(Number.POSITIVE_INFINITY) .caret(Number.POSITIVE_INFINITY)
.animate({scrollTop: height}, "fast"); .animate({scrollTop: height}, "fast");
@@ -1188,9 +1207,9 @@ export function process_hotkey(e, hotkey) {
if (inbox_ui.is_in_focus()) { if (inbox_ui.is_in_focus()) {
return inbox_ui.toggle_topic_visibility_policy(); return inbox_ui.toggle_topic_visibility_policy();
} }
if (message_lists.current.selected_message()) { if (message_lists.current!.selected_message()) {
user_topics_ui.toggle_topic_visibility_policy( user_topics_ui.toggle_topic_visibility_policy(
message_lists.current.selected_message(), message_lists.current!.selected_message()!,
); );
return true; return true;
} }
@@ -1322,6 +1341,7 @@ export function process_hotkey(e, hotkey) {
} }
const msg = message_lists.current.selected_message(); const msg = message_lists.current.selected_message();
assert(msg !== undefined);
// Shortcuts that operate on a message // Shortcuts that operate on a message
switch (event_name) { switch (event_name) {
@@ -1364,9 +1384,9 @@ export function process_hotkey(e, hotkey) {
$emoji_icon?.length !== 0 && $emoji_icon?.length !== 0 &&
$emoji_icon.closest(".message_control_button").css("display") !== "none" $emoji_icon.closest(".message_control_button").css("display") !== "none"
) { ) {
emoji_picker_reference = $emoji_icon[0]; emoji_picker_reference = util.the($emoji_icon);
} else { } else {
emoji_picker_reference = $row.find(".message-actions-menu-button")[0]; emoji_picker_reference = util.the($row.find(".message-actions-menu-button"));
} }
emoji_picker.toggle_emoji_popover(emoji_picker_reference, msg.id, { emoji_picker.toggle_emoji_popover(emoji_picker_reference, msg.id, {
@@ -1378,7 +1398,7 @@ export function process_hotkey(e, hotkey) {
// '+': reacts with thumbs up emoji on selected message // '+': reacts with thumbs up emoji on selected message
// Use canonical name. // Use canonical name.
const thumbs_up_emoji_code = "1f44d"; const thumbs_up_emoji_code = "1f44d";
const canonical_name = emoji.get_emoji_name(thumbs_up_emoji_code); const canonical_name = emoji.get_emoji_name(thumbs_up_emoji_code)!;
reactions.toggle_emoji_reaction(msg, canonical_name); reactions.toggle_emoji_reaction(msg, canonical_name);
return true; return true;
} }
@@ -1434,8 +1454,13 @@ export function process_hotkey(e, hotkey) {
if (!message_edit.can_move_message(msg)) { if (!message_edit.can_move_message(msg)) {
return false; return false;
} }
assert(msg.type === "stream");
stream_popover.build_move_topic_to_stream_popover(msg.stream_id, msg.topic, false, msg); void stream_popover.build_move_topic_to_stream_popover(
msg.stream_id,
msg.topic,
false,
msg,
);
return true; return true;
} }
case "toggle_read_receipts": { case "toggle_read_receipts": {
@@ -1476,7 +1501,7 @@ export function process_hotkey(e, hotkey) {
return false; return false;
} }
export function process_keydown(e) { export function process_keydown(e: JQuery.KeyDownEvent): boolean {
activity.set_new_user_input(true); activity.set_new_user_input(true);
const result = get_keydown_hotkey(e); const result = get_keydown_hotkey(e);
if (!result) { if (!result) {
@@ -1489,7 +1514,7 @@ export function process_keydown(e) {
return process_hotkey(e, result); return process_hotkey(e, result);
} }
export function initialize() { export function initialize(): void {
$(document).on("keydown", (e) => { $(document).on("keydown", (e) => {
if (e.key === undefined) { if (e.key === undefined) {
/* Some browsers trigger a 'keydown' event with `key === undefined` /* Some browsers trigger a 'keydown' event with `key === undefined`

View File

@@ -1,5 +1,5 @@
/* /*
See hotkey.js for handlers that are more app-wide. See hotkey.ts for handlers that are more app-wide.
*/ */
export const vim_left = "h"; export const vim_left = "h";

View File

@@ -55,7 +55,7 @@ import * as giphy from "./giphy.ts";
import * as giphy_state from "./giphy_state.ts"; import * as giphy_state from "./giphy_state.ts";
import * as group_permission_settings from "./group_permission_settings.ts"; import * as group_permission_settings from "./group_permission_settings.ts";
import * as hashchange from "./hashchange.ts"; import * as hashchange from "./hashchange.ts";
import * as hotkey from "./hotkey.js"; import * as hotkey from "./hotkey.ts";
import * as i18n from "./i18n.ts"; import * as i18n from "./i18n.ts";
import * as inbox_ui from "./inbox_ui.ts"; import * as inbox_ui from "./inbox_ui.ts";
import * as information_density from "./information_density.ts"; import * as information_density from "./information_density.ts";

View File

@@ -17,7 +17,7 @@ const {page_params} = require("./lib/zpage_params.cjs");
// functions (like overlays.settings_open). Within that context, to // functions (like overlays.settings_open). Within that context, to
// test whether a given key (e.g. `x`) results in a specific function // test whether a given key (e.g. `x`) results in a specific function
// (e.g. `ui.foo()`), we fail to import any modules other than // (e.g. `ui.foo()`), we fail to import any modules other than
// hotkey.js so that accessing them will result in a ReferenceError. // hotkey.ts so that accessing them will result in a ReferenceError.
// //
// Then we create a stub `ui.foo`, and call the hotkey function. If // Then we create a stub `ui.foo`, and call the hotkey function. If
// it calls any external module other than `ui.foo`, it'll crash. // it calls any external module other than `ui.foo`, it'll crash.
@@ -37,7 +37,7 @@ set_global("document", {
const activity_ui = mock_esm("../src/activity_ui"); const activity_ui = mock_esm("../src/activity_ui");
const activity = zrequire("../src/activity"); const activity = zrequire("../src/activity");
const browser_history = mock_esm("../src/browser_history"); const browser_history = mock_esm("../src/browser_history", {go_to_location() {}});
const compose_actions = mock_esm("../src/compose_actions"); const compose_actions = mock_esm("../src/compose_actions");
const compose_reply = mock_esm("../src/compose_reply"); const compose_reply = mock_esm("../src/compose_reply");
const condense = mock_esm("../src/condense"); const condense = mock_esm("../src/condense");
@@ -106,7 +106,7 @@ message_lists.current = {
}, },
selected_row() { selected_row() {
const $row = $.create("selected-row-stub"); const $row = $.create("selected-row-stub");
$row.set_find_results(".message-actions-menu-button", []); $row.set_find_results(".message-actions-menu-button", ["<menu-button-stub>"]);
$row.set_find_results(".emoji-message-control-button-container", { $row.set_find_results(".emoji-message-control-button-container", {
closest: () => ({css: () => "none"}), closest: () => ({css: () => "none"}),
}); });
@@ -114,6 +114,8 @@ message_lists.current = {
}, },
selected_message() { selected_message() {
return { return {
type: "stream",
stream_id: 2,
sent_by_me: true, sent_by_me: true,
flags: ["read", "starred"], flags: ["read", "starred"],
}; };
@@ -355,11 +357,11 @@ function test_normal_typing() {
assert_unmapped('~!@#$%^*()_+{}:"<>'); assert_unmapped('~!@#$%^*()_+{}:"<>');
} }
test_while_not_editing_text("unmapped keys return false easily", () => { run_test("unmapped keys return false easily", () => {
// Unmapped keys should immediately return false, without // Unmapped keys should immediately return false, without
// calling any functions outside of hotkey.js. // calling any functions outside of hotkey.ts.
// (unless we are editing text) // (unless we are editing text)
assert_unmapped("bfoyz"); assert_unmapped("bfo");
assert_unmapped("BEFLOQTWXYZ"); assert_unmapped("BEFLOQTWXYZ");
}); });