/
githubmirror
/
zulip
Обзор
Документация
Войти
/
githubmirror
/
zulip
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
web/src/hotkey.ts
1 584 строки
57 KB
Anders Kaseorg
eslint: Fix unicorn/no-computed-property-existence-check.
29 июл 2026, 02:35
29 июл 2026, 02:35
1164601
Код
Авторство
О чём код?
import {$} from "jquery"; import assert from "minimalistic-assert"; import * as activity from "./activity.ts"; import * as activity_ui from "./activity_ui.ts"; import * as browser_history from "./browser_history.ts"; import * as clipboard_handler from "./clipboard_handler.ts"; import * as color_picker_popover from "./color_picker_popover.ts"; import * as common from "./common.ts"; import * as compose from "./compose.ts"; import * as compose_actions from "./compose_actions.ts"; import * as compose_banner from "./compose_banner.ts"; import * as compose_recipient from "./compose_recipient.ts"; import * as compose_reply from "./compose_reply.ts"; import * as compose_send_menu_popover from "./compose_send_menu_popover.ts"; import * as compose_state from "./compose_state.ts"; import * as compose_textarea from "./compose_textarea.ts"; import * as compose_tooltips from "./compose_tooltips.ts"; import * as condense from "./condense.ts"; import {show_copied_confirmation} from "./copied_tooltip.ts"; import * as deprecated_feature_notice from "./deprecated_feature_notice.ts"; import * as drafts_overlay_ui from "./drafts_overlay_ui.ts"; import * as emoji from "./emoji.ts"; import * as emoji_picker from "./emoji_picker.ts"; import * as feedback_widget from "./feedback_widget.ts"; import * as gear_menu from "./gear_menu.ts"; import * as gif_picker_ui from "./gif_picker_ui.ts"; import * as hash_util from "./hash_util.ts"; import * as hashchange from "./hashchange.ts"; import {$t} from "./i18n.ts"; import * as inbox_ui from "./inbox_ui.ts"; import * as inbox_util from "./inbox_util.ts"; import * as info_overlay from "./info_overlay.ts"; import * as lightbox from "./lightbox.ts"; import * as list_util from "./list_util.ts"; import * as message_actions_popover from "./message_actions_popover.ts"; import * as message_edit from "./message_edit.ts"; import * as message_edit_history from "./message_edit_history.ts"; import * as message_lists from "./message_lists.ts"; import * as message_scroll_state from "./message_scroll_state.ts"; import * as message_view from "./message_view.ts"; import * as modals from "./modals.ts"; import * as narrow_state from "./narrow_state.ts"; import * as navbar_menus from "./navbar_menus.ts"; import * as navigate from "./navigate.ts"; import * as overlays from "./overlays.ts"; import {page_params} from "./page_params.ts"; import * as playground_links_popover from "./playground_links_popover.ts"; import * as pm_list from "./pm_list.ts"; import * as popover_menus from "./popover_menus.ts"; import * as popovers from "./popovers.ts"; import * as reactions from "./reactions.ts"; import * as read_receipts from "./read_receipts.ts"; import * as recent_view_ui from "./recent_view_ui.ts"; import * as recent_view_util from "./recent_view_util.ts"; import * as reminders_overlay_ui from "./reminders_overlay_ui.ts"; import * as saved_snippets_ui from "./saved_snippets_ui.ts"; import * as scheduled_messages_overlay_ui from "./scheduled_messages_overlay_ui.ts"; import * as search from "./search.ts"; import {message_edit_history_visibility_policy_values} from "./settings_config.ts"; import * as settings_data from "./settings_data.ts"; import * as sidebar_ui from "./sidebar_ui.ts"; import * as spectators from "./spectators.ts"; import * as starred_messages_ui from "./starred_messages_ui.ts"; import {realm} from "./state_data.ts"; import * as stream_list from "./stream_list.ts"; import * as stream_popover from "./stream_popover.ts"; import * as stream_settings_ui from "./stream_settings_ui.ts"; import * as topic_list from "./topic_list.ts"; import * as unread_ops from "./unread_ops.ts"; import * as user_card_popover from "./user_card_popover.ts"; import * as user_group_popover from "./user_group_popover.ts"; import {user_settings} from "./user_settings.ts"; import * as user_status_ui from "./user_status_ui.ts"; import * as user_topics_ui from "./user_topics_ui.ts"; import * as util from "./util.ts"; function do_narrow_action( action: ( target_id: number, opts: { trigger: string; }, ) => void, ): boolean { if (message_lists.current === undefined) { return false; } action(message_lists.current.selected_id(), {trigger: "hotkey"}); return true; } // For message actions and user profile menu. const menu_dropdown_hotkeys = new Set(["down_arrow", "up_arrow", "vim_up", "vim_down", "enter"]); const color_picker_hotkeys = new Set([ "down_arrow", "up_arrow", "left_arrow", "right_arrow", "vim_up", "vim_down", "vim_left", "vim_right", "enter", ]); // A set of characters that, on many keyboard layouts, require 'Shift' modifier key // to type, or where pressing the key with a 'Shift' modifier is expected to produce // a different character. We ignore the 'Shift' modifier when processing these keys, // and disallow assigning hotkeys that involve 'Shift' modifier combined with them. 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 // we'll do in cases where they have the exact same semantics. // DON'T FORGET: update keyboard_shortcuts.html // The `message_view_only` property is a convenient and performant way // to express a common case of which hotkeys do something in which // views. It is set for hotkeys (like `Ctrl + S`) that only have an effect // in the main message view with a selected message. // `message_view_only` hotkeys, as a group, are not processed if any // overlays are open (e.g. settings, streams, etc.). const KEYDOWN_MAPPINGS: Record<string, Hotkey | Hotkey[]> = { "Alt+P": {name: "toggle_compose_preview", message_view_only: true}, "Ctrl+[": {name: "escape", message_view_only: false}, "Cmd+Enter": {name: "action_with_enter", message_view_only: true}, "Cmd+C": {name: "copy_with_c", message_view_only: false}, "Cmd+K": {name: "search_with_k", message_view_only: false}, "Cmd+P": {name: "print", message_view_only: false}, "Cmd+@": {name: "open_mentions_view", message_view_only: true}, "Cmd+S": {name: "star_message", message_view_only: true}, "Cmd+.": {name: "narrow_to_compose_target", message_view_only: true}, "Cmd+'": {name: "open_saved_snippet_dropdown", message_view_only: true}, "Ctrl+Enter": {name: "action_with_enter", message_view_only: true}, "Ctrl+C": {name: "copy_with_c", message_view_only: false}, "Ctrl+K": {name: "search_with_k", message_view_only: false}, "Ctrl+P": {name: "print", message_view_only: false}, "Ctrl+@": {name: "open_mentions_view", message_view_only: true}, "Ctrl+S": {name: "star_message", message_view_only: true}, "Ctrl+.": {name: "narrow_to_compose_target", message_view_only: true}, "Ctrl+'": {name: "open_saved_snippet_dropdown", message_view_only: true}, "Shift+A": {name: "stream_cycle_backward", message_view_only: true}, "Shift+C": {name: "C_deprecated", message_view_only: true}, "Shift+D": {name: "stream_cycle_forward", message_view_only: true}, "Shift+G": {name: "G_end", message_view_only: true}, "Shift+H": {name: "view_edit_history", message_view_only: true}, "Shift+I": {name: "open_inbox", message_view_only: true}, "Shift+J": {name: "vim_page_down", message_view_only: true}, "Shift+K": {name: "vim_page_up", message_view_only: true}, "Shift+M": {name: "toggle_topic_visibility_policy", message_view_only: true}, "Shift+N": {name: "narrow_to_next_unread_followed_topic", message_view_only: false}, "Shift+P": {name: "narrow_private", message_view_only: true}, "Shift+R": {name: "respond_to_author", message_view_only: true}, "Shift+S": {name: "toggle_stream_subscription", message_view_only: true}, "Shift+U": {name: "mark_unread", message_view_only: true}, "Shift+V": [ {name: "view_selected_stream", message_view_only: false}, {name: "toggle_read_receipts", message_view_only: true}, ], "Shift+Y": {name: "set_status", message_view_only: false}, "Shift+Tab": {name: "shift_tab", message_view_only: false}, "Shift+ ": {name: "shift_spacebar", message_view_only: true}, "Shift+ArrowLeft": {name: "left_arrow", message_view_only: false}, "Shift+ArrowRight": {name: "right_arrow", message_view_only: false}, "Shift+ArrowUp": {name: "up_arrow", message_view_only: false}, "Shift+ArrowDown": {name: "down_arrow", message_view_only: false}, // The shortcut "A" dates from when this was called "All messages". A: {name: "open_combined_feed", message_view_only: true}, C: {name: "compose", message_view_only: true}, D: {name: "open_drafts", message_view_only: true}, E: {name: "edit_message", message_view_only: true}, G: {name: "gear_menu", message_view_only: true}, H: {name: "vim_left", message_view_only: true}, I: {name: "message_actions", message_view_only: true}, J: {name: "vim_down", message_view_only: true}, K: {name: "vim_up", message_view_only: true}, L: {name: "vim_right", message_view_only: true}, M: {name: "move_message", message_view_only: true}, N: {name: "n_key", message_view_only: false}, P: {name: "p_key", message_view_only: false}, Q: {name: "query_streams", message_view_only: true}, R: {name: "reply_message", message_view_only: true}, S: {name: "toggle_conversation_view", message_view_only: true}, T: {name: "open_recent_view", message_view_only: true}, U: {name: "toggle_sender_info", message_view_only: true}, V: {name: "show_lightbox", message_view_only: true}, W: {name: "query_users", message_view_only: true}, X: {name: "compose_private_message", message_view_only: true}, Y: {name: "list_of_channel_topics", message_view_only: true}, Z: {name: "zoom_to_message_near", message_view_only: true}, Tab: {name: "tab", message_view_only: false}, Escape: {name: "escape", message_view_only: false}, " ": {name: "spacebar", message_view_only: true}, PageUp: {name: "page_up", message_view_only: true}, PageDown: {name: "page_down", message_view_only: true}, End: {name: "end", message_view_only: true}, Home: {name: "home", message_view_only: true}, ArrowLeft: {name: "left_arrow", message_view_only: false}, ArrowRight: {name: "right_arrow", message_view_only: false}, ArrowUp: {name: "up_arrow", message_view_only: false}, ArrowDown: {name: "down_arrow", message_view_only: false}, "*": {name: "open_starred_message_view", message_view_only: true}, "+": {name: "thumbs_up_emoji", message_view_only: true}, "=": {name: "upvote_first_emoji", message_view_only: true}, "-": {name: "toggle_message_collapse", message_view_only: true}, "/": {name: "search", message_view_only: false}, ":": {name: "toggle_reactions_popover", message_view_only: true}, "<": {name: "compose_forward_message", message_view_only: true}, ">": {name: "compose_quote_message", message_view_only: true}, "?": {name: "show_shortcuts", message_view_only: false}, "@": {name: "compose_reply_with_mention", message_view_only: true}, // these can be triggered by key or Shift + key // // We may want to revisit both of these. For Backspace, we don't // have any specific mapping behavior; we are just trying to disable // the normal browser features for certain OSes when we are in the // compose box, and the little bit of Backspace-related code here is // dubious, but may apply to Shift-Backspace. // For Enter, there is some possibly that Shift-Enter is intended to // have special behavior for folks that are used to Shift-Enter behavior // in other apps, but that's also slightly dubious. Backspace: {name: "backspace", message_view_only: true}, Enter: {name: "enter", message_view_only: false}, Delete: {name: "delete", message_view_only: false}, "Shift+Backspace": {name: "backspace", message_view_only: true}, "Shift+Enter": {name: "enter", message_view_only: false}, "Shift+Delete": {name: "delete", message_view_only: false}, }; const KNOWN_NAMED_KEY_ATTRIBUTE_VALUES = new Set([ "Alt", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp", "Backspace", "Control", "Delete", "End", "Enter", "Escape", "Home", "Meta", "PageDown", "PageUp", "Shift", "Tab", ]); const CODE_TO_QWERTY_CHAR: Record<string, string> = { KeyA: "a", KeyB: "b", KeyC: "c", KeyD: "d", KeyE: "e", KeyF: "f", KeyG: "g", KeyH: "h", KeyI: "i", KeyJ: "j", KeyK: "k", KeyL: "l", KeyM: "m", KeyN: "n", KeyO: "o", KeyP: "p", KeyQ: "q", KeyR: "r", KeyS: "s", KeyT: "t", KeyU: "u", KeyV: "v", KeyW: "w", KeyX: "x", KeyY: "y", KeyZ: "z", "Shift+KeyA": "A", "Shift+KeyB": "B", "Shift+KeyC": "C", "Shift+KeyD": "D", "Shift+KeyE": "E", "Shift+KeyF": "F", "Shift+KeyG": "G", "Shift+KeyH": "H", "Shift+KeyI": "I", "Shift+KeyJ": "J", "Shift+KeyK": "K", "Shift+KeyL": "L", "Shift+KeyM": "M", "Shift+KeyN": "N", "Shift+KeyO": "O", "Shift+KeyP": "P", "Shift+KeyQ": "Q", "Shift+KeyR": "R", "Shift+KeyS": "S", "Shift+KeyT": "T", "Shift+KeyU": "U", "Shift+KeyV": "V", "Shift+KeyW": "W", "Shift+KeyX": "X", "Shift+KeyY": "Y", "Shift+KeyZ": "Z", BracketLeft: "[", Equal: "=", Minus: "-", Period: ".", Quote: "'", "Shift+Comma": "<", "Shift+Digit2": "@", "Shift+Digit8": "*", "Shift+Equal": "+", "Shift+Period": ">", "Shift+Semicolon": ":", "Shift+Slash": "?", Slash: "/", Space: " ", }; // Keyboard Event Viewer tool: // https://w3c.github.io/uievents/tools/key-event-viewer.html export function get_keydown_hotkey(e: JQuery.KeyDownEvent): Hotkey | Hotkey[] | undefined { // Determine the key pressed in a consistent way. // // For keyboard layouts (e.g. QWERTY) where `e.key` results // in printable ASCII characters or named key attribute values // like "Tab", "Enter", etc., we use `e.key` directly. // // However, for layouts where the character defined for a hotkey // can't be typed directly (e.g. 'a' in Cyrillic), users press // the same physical key combination that a QWERTY user would // use (e.g. 'ф' on Cyrillic maps to 'a' on QWERTY). In such cases, // we derive the QWERTY equivalent using `e.code` and the `CODE_TO_QWERTY_CHAR` map. // // The same `e.code` path is needed on macOS when Cmd+Shift are // both held, as browsers report the unshifted key in that case // (e.g. Cmd+Shift+2 gives e.key="2" rather than "@"). Without // this, `Cmd+<shifted_symbol>` hotkeys would never match. const mac_cmd_shift = common.has_mac_keyboard() && e.metaKey && e.shiftKey; const use_event_key = KNOWN_NAMED_KEY_ATTRIBUTE_VALUES.has(e.key) || (common.is_printable_ascii(e.key) && !mac_cmd_shift); let key = e.key; if (!use_event_key) { const code = `${e.shiftKey ? "Shift+" : ""}${e.code}`; if (CODE_TO_QWERTY_CHAR[code] !== undefined) { key = CODE_TO_QWERTY_CHAR[code]; } else { return undefined; } } if (common.has_mac_keyboard() && e.ctrlKey && key !== "[") { // On macOS, Cmd is used instead of Ctrl. Except 'Ctrl + ['. return undefined; } if (!common.has_mac_keyboard() && e.metaKey) { return undefined; } // Make lowercase a-z uppercase; leave others unchanged. // We distinguish between 'a' and 'A' as 'A' vs 'Shift+A'. // This helps to avoid Caps Lock affecting shortcuts. if (/^[a-z]$/.test(key)) { key = key.toUpperCase(); } if (e.shiftKey && !KNOWN_IGNORE_SHIFT_MODIFIER_KEYS.has(key)) { key = "Shift+" + key; } if (e.altKey) { key = "Alt+" + key; } if (e.ctrlKey) { key = "Ctrl+" + key; } if (e.metaKey) { assert(common.has_mac_keyboard()); key = "Cmd+" + key; } return KEYDOWN_MAPPINGS[key]; } export let processing_text = (): boolean => { const $focused_elt = $(":focus"); return ( $focused_elt.is("input") || $focused_elt.is("select") || $focused_elt.is("textarea") || $focused_elt.parents(".pill-container").length > 0 || $focused_elt.attr("id") === "compose-send-button" || $focused_elt.parents(".dropdown-list-container").length > 0 ); }; export function rewire_processing_text(value: typeof processing_text): void { processing_text = value; } // Returns true if we handled it, false if the browser should. function process_escape_key(e: JQuery.KeyDownEvent): boolean { assert(e.target instanceof HTMLElement); if ( recent_view_ui.is_in_focus() && // This will return false if `e.target` is not // any of the Recent Conversations elements by design. recent_view_ui.change_focused_element($(e.target), "escape") ) { // Recent Conversations uses escape to switch focus from // search / filters to the conversations table. If focus is // already on the table, it returns false. return true; } if (inbox_ui.is_in_focus() && inbox_ui.change_focused_element("escape")) { return true; } if (feedback_widget.is_open()) { feedback_widget.dismiss(); return true; } if (gif_picker_ui.is_popped_from_edit_message()) { gif_picker_ui.focus_current_edit_message(); // Hide after setting focus so that `edit_message_id` is // still set in picker. gif_picker_ui.hide_picker_popover(); return true; } // Hide the GIF picker if it was open and focus the compose textarea. if (!gif_picker_ui.is_popped_from_edit_message() && gif_picker_ui.hide_picker_popover()) { $("textarea#compose-textarea").trigger("focus"); return true; } if (popovers.any_active()) { popovers.hide_all(); return true; } if (modals.any_active()) { modals.close_active(); return true; } if (overlays.any_active()) { overlays.close_active(); return true; } if (navbar_menus.any_focused()) { navbar_menus.blur_focused(); return true; } if (processing_text()) { if (activity_ui.searching()) { activity_ui.clear_search(); return true; } if (stream_list.searching()) { stream_list.clear_search(); return true; } // Emoji picker goes before compose so compose emoji picker is closed properly. if (emoji_picker.is_open()) { emoji_picker.hide_emoji_popover(); return true; } // When the input is focused, we blur and clear the input. A second "Esc" // will zoom out, handled below. if (stream_list.is_zoomed_in() && $("#topic_filter_query").is(":focus")) { topic_list.clear_topic_search(e); return true; } if (pm_list.is_zoomed_in() && $(".direct-messages-list-filter").is(":focus")) { pm_list.clear_search(); return true; } if (compose_state.composing()) { // Check if the GIF picker popover was open using compose box. // Hide GIF picker popover if it's open. if ( !gif_picker_ui.is_popped_from_edit_message() && gif_picker_ui.hide_picker_popover() ) { $("textarea#compose-textarea").trigger("focus"); return true; } // Check for errors in compose box; close errors if they exist if ($("main-view-banner").length > 0) { compose_banner.clear_all(); return true; } // If the user hit the Esc key, cancel the current compose compose_actions.cancel(); return true; } // We pressed Esc and something was focused, and the composebox // wasn't open. In that case, we should blur the input. $("input:focus,textarea:focus").trigger("blur"); return true; } if (sidebar_ui.any_sidebar_expanded_as_overlay()) { sidebar_ui.hide_all(); return true; } if (compose_state.composing()) { compose_actions.cancel(); return true; } if (stream_list.is_zoomed_in()) { stream_list.zoom_out(); return true; } /* The Ctrl+[ hotkey navigates to the home view * unconditionally; Esc's behavior depends on a setting. */ if (user_settings.web_escape_navigates_to_home_view || e.key === "[") { const triggered_by_escape_key = true; hashchange.set_hash_to_home_view(triggered_by_escape_key); return true; } return false; } function handle_popover_events(event_name: string): boolean { const popover_menu_visible_instance = popover_menus.get_visible_instance(); if (popover_menus.is_stream_actions_popover_displayed()) { stream_popover.stream_sidebar_menu_handle_keyboard(event_name); return true; } if (popover_menu_visible_instance) { const $focused_element = $(":focus"); const focus_in_textarea = $focused_element.is("textarea"); const focused_element = $focused_element[0]; const focus_in_textarea_at_end = focus_in_textarea && event_name === "down_arrow" && focused_element instanceof HTMLTextAreaElement && focused_element.selectionStart === focused_element.value.length && focused_element.selectionEnd === focused_element.value.length; if ( // Allow all hotkeys except for `down_arrow` and `up_arrow` // to be handled by the browser. // The `down_arrow` key is specifically intercepted when the cursor // is at the end of a textarea to enable popover navigation. processing_text() && ((focus_in_textarea && !focus_in_textarea_at_end) || (event_name !== "down_arrow" && event_name !== "up_arrow")) ) { return false; } popover_menus.sidebar_menu_instance_handle_keyboard( popover_menu_visible_instance, event_name, ); return true; } if (user_card_popover.message_user_card.is_open()) { user_card_popover.message_user_card.handle_keyboard(event_name); return true; } if (user_card_popover.user_card.is_open()) { user_card_popover.user_card.handle_keyboard(event_name); return true; } if (user_card_popover.user_sidebar.is_open()) { user_card_popover.user_sidebar.handle_keyboard(event_name); return true; } if (user_group_popover.is_open()) { user_group_popover.handle_keyboard(event_name); return true; } if (playground_links_popover.is_open()) { playground_links_popover.handle_keyboard(event_name); return true; } return false; } // Returns true if we handled it, false if the browser should. function process_enter_key(e: JQuery.KeyDownEvent): boolean { if ($(e.target).hasClass("trigger-click-on-enter")) { // If the target has the class "trigger-click-on-enter", explicitly // trigger a click event on it to call the associated click handler. e.preventDefault(); $(e.target).trigger("click"); return true; } if (popovers.any_active() && $(e.target).hasClass("navigate-link-on-enter")) { // If a popover is open and we pressed Enter on a menu item, // call click directly on the item to navigate to the `href`. // trigger("click") doesn't work for them to navigate to `href`. assert(e.target instanceof HTMLElement); e.target.click(); e.preventDefault(); popovers.hide_all(); return true; } if (emoji_picker.is_open()) { return emoji_picker.navigate("enter", e); } if (handle_popover_events("enter")) { return true; } if (overlays.settings_open()) { // On the settings page just let the browser handle // the Enter key for things like submitting forms. return false; } if (overlays.streams_open()) { return false; } if (processing_text()) { // Don't send the message if topic box is focused. if (compose.is_topic_input_focused()) { return true; } return false; } // This handles when pressing Enter while looking at drafts. // It restores draft that is focused. if (overlays.drafts_open()) { const $draft_overlay = $("#draft_overlay"); if ($draft_overlay.find("a:focus, button:focus, input:focus").length > 0) { return false; } drafts_overlay_ui.handle_keyboard_events("enter"); return true; } if (overlays.scheduled_messages_open()) { scheduled_messages_overlay_ui.handle_keyboard_events("enter"); return true; } if (overlays.reminders_open()) { reminders_overlay_ui.handle_keyboard_events("enter"); return true; } // Transfer the enter keypress from button to the `<i>` tag inside // 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. assert(e.target instanceof HTMLElement); if (e.target.id === "send_later") { compose_send_menu_popover.toggle(); return true; } if ($(e.target).attr("role") === "button") { e.target.click(); return true; } // All custom logic for overlays/modals is above; if we're in a // modal at this point, let the browser handle the event. if (modals.any_active()) { return false; } // If we're on a button or a link and have pressed Enter, let the // browser handle the keypress // // This is subtle and here's why: Suppose you have the focus on a // stream name in your left sidebar. j and k will still move your // cursor up and down, but Enter won't reply -- it'll just trigger // the link on the sidebar! So you keep pressing Enter over and // over again. Until you click somewhere or press r. if ($("a:focus,button:focus").length > 0) { return false; } if ($("#compose").hasClass("preview_mode")) { compose.handle_enter_key_with_preview_open(); return true; } if (recent_view_util.is_visible()) { if (e.target === $("body")[0]) { // There's a race when using `Esc` and `Enter` to navigate to // Recent Conversations and then navigate to the next topic, wherein // Recent Conversations won't have applied focus to its table yet. // // Recent Conversations' own navigation just lets `Enter` be // treated as a click on the highlighted message, so we // don't need to do anything there. But if nothing is // focused (say, during the race or after clicking on the // sidebars, it's worth focusing the table so that hitting // `Enter` again will navigate you somewhere. const focus_changed = recent_view_ui.revive_current_focus(); return focus_changed; } // Never fall through to opening the compose box to reply. return false; } if (!narrow_state.is_message_feed_visible()) { return false; } // For search views, renarrow to the current message's // conversation. const current_filter = narrow_state.filter(); if (current_filter !== undefined && !current_filter.contains_no_partial_conversations()) { assert(message_lists.current !== undefined); const message = message_lists.current.selected_message(); if (message === undefined) { // No selected message. return false; } window.location.assign(hash_util.by_conversation_and_time_url(message)); return true; } compose_reply.respond_to_message({trigger: "hotkey enter"}); return true; } export function process_cmd_or_ctrl_enter_key(): boolean { if ($("#compose").hasClass("preview_mode")) { const cmd_or_ctrl_pressed = true; compose.handle_enter_key_with_preview_open(cmd_or_ctrl_pressed); return true; } return false; } export function process_tab_key(): boolean { // Returns true if we handled it, false if the browser should. // TODO: See if browsers like Safari can now handle tabbing correctly // without our intervention. if (emoji_picker.is_open()) { return emoji_picker.navigate("tab"); } return false; } export function process_shift_tab_key(): boolean { // Returns true if we handled it, false if the browser should. // TODO: See if browsers like Safari can now handle tabbing correctly // without our intervention. const focused_element = document.activeElement; if (!(focused_element instanceof HTMLElement)) { return false; } if (focused_element.id === "compose-send-button") { // Shift-Tab: go back to content textarea and restore // cursor position. compose_textarea.restore_compose_cursor(); return true; } // Shift-Tabbing from emoji catalog/search results takes you back to search textbox. if (emoji_picker.is_open()) { return emoji_picker.navigate("shift_tab"); } if ($("input#stream_message_recipient_topic").is(":focus")) { compose_recipient.toggle_compose_recipient_dropdown(); return true; } return false; } // Process a keydown event. // // Returns true if we handled it, false if the browser should. function process_hotkey(e: JQuery.KeyDownEvent, hotkey: Hotkey): boolean { const event_name = hotkey.name; // This block needs to be before the `Tab` handler. switch (event_name) { case "up_arrow": case "down_arrow": case "left_arrow": case "right_arrow": case "page_down": case "page_up": case "vim_up": case "vim_down": case "vim_left": case "vim_right": case "tab": case "shift_tab": case "open_recent_view": if (recent_view_ui.is_in_focus()) { assert(e.target instanceof HTMLElement); return recent_view_ui.change_focused_element($(e.target), event_name); } } switch (event_name) { case "up_arrow": case "down_arrow": case "left_arrow": case "right_arrow": case "tab": case "shift_tab": case "vim_up": case "vim_down": case "vim_left": case "vim_right": case "page_up": case "page_down": if (inbox_ui.is_in_focus()) { return inbox_ui.change_focused_element(event_name); } } // We handle the most complex keys in their own functions. switch (event_name) { case "escape": return process_escape_key(e); case "enter": return process_enter_key(e); case "action_with_enter": return process_cmd_or_ctrl_enter_key(); case "tab": return process_tab_key(); case "shift_tab": return process_shift_tab_key(); } // This block needs to be before the open modals check, because // the "user status" modal can show the emoji picker. if (emoji_picker.is_open()) { return emoji_picker.navigate(event_name); } // modals.any_active() and modals.active_modal() both query the dom to // find and retrieve any active modal. Thus, we limit the number of calls // to the DOM by storing these values as constants to be reused. const is_any_modal_active = modals.any_active(); const active_modal = is_any_modal_active ? modals.active_modal() : null; // `list_util` will process the event in send later modal. if (is_any_modal_active && active_modal !== "#send_later_modal") { if (event_name === "toggle_read_receipts" && active_modal === "#read_receipts_modal") { read_receipts.hide_user_list(); return true; } return false; } // TODO: break out specific handlers for up_arrow, // down_arrow, and backspace switch (event_name) { case "up_arrow": case "down_arrow": case "vim_up": case "vim_down": case "backspace": case "delete": if (overlays.drafts_open()) { drafts_overlay_ui.handle_keyboard_events(event_name); return true; } if (overlays.scheduled_messages_open()) { scheduled_messages_overlay_ui.handle_keyboard_events(event_name); return true; } if (overlays.reminders_open()) { reminders_overlay_ui.handle_keyboard_events(event_name); return true; } if (overlays.message_edit_history_open()) { message_edit_history.handle_keyboard_events(event_name); return true; } } if (hotkey.message_view_only && overlays.any_active()) { if (processing_text()) { return false; } if (event_name === "toggle_stream_subscription" && overlays.streams_open()) { stream_settings_ui.keyboard_sub(); return true; } if (event_name === "show_lightbox" && overlays.lightbox_open()) { overlays.close_overlay("lightbox"); return true; } if (event_name === "open_drafts" && overlays.drafts_open()) { overlays.close_overlay("drafts"); return true; } return false; } if (overlays.settings_open() && !user_card_popover.user_card.is_open()) { return false; } if (overlays.info_overlay_open()) { if (event_name === "show_shortcuts") { overlays.close_active(); return true; } if (event_name === "print") { info_overlay.print_current_pane(); return true; } return false; } if (event_name === "print") { // Only the informational overlays have special print // handling; everywhere else, the browser's print of the // current page (styled by print.css) is what we want. return false; } // We don't treat the color picker like our menu popovers, since it // supports sideways navigation (left and right arrow). if (color_picker_hotkeys.has(event_name) && popover_menus.is_color_picker_popover_displayed()) { color_picker_popover.handle_keyboard(event_name); return true; } if ((event_name === "up_arrow" || event_name === "down_arrow") && overlays.streams_open()) { return stream_settings_ui.switch_rows(event_name); } if (event_name === "up_arrow" && list_util.inside_list(e)) { list_util.go_up(e); return true; } if (event_name === "down_arrow" && list_util.inside_list(e)) { list_util.go_down(e); return true; } if (event_name === "toggle_compose_preview") { const 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.closest(".preview_mode").length > 0) { message_edit.clear_preview_area($last_focused_compose_type_input); } else { message_edit.show_preview_area($last_focused_compose_type_input); } return true; } if (compose_state.composing()) { if ($("#compose").hasClass("preview_mode")) { compose.clear_preview_area(); } else { compose.show_preview_area(); } return true; } } // Handle our normal popovers that are basically vertical lists of menu items. if (menu_dropdown_hotkeys.has(event_name) && handle_popover_events(event_name)) { return true; } // Handle the left arrow and right arrow keys to make it easy to // get into fancy sideways navigation on the navbar (top right corner). if ( (navbar_menus.is_navbar_menus_displayed() || navbar_menus.any_focused()) && navbar_menus.handle_keyboard_events(event_name) ) { return true; } // The next two sections date back to 00445c84 and are Mac/Chrome-specific, // and they should possibly be eliminated in favor of keeping standard // browser behavior. if (event_name === "backspace" && $("#compose-send-button").is(":focus")) { // Ignore Backspace; don't navigate back a page. return true; } if (event_name === "narrow_to_compose_target") { compose_tooltips.dismiss_intro_go_to_conversation_tooltip(); message_view.to_compose_target(); return true; } // Process hotkeys specially when in an input, select, textarea, or send button if (processing_text()) { // Note that there is special handling for Enter/Esc too, but // we handle this in other functions. if (event_name === "open_saved_snippet_dropdown") { const $messagebox = $(":focus").parents(".messagebox"); if ($messagebox.length === 1) { saved_snippets_ui.open_saved_snippets_dropdown_via_hotkey(); } } if (event_name === "left_arrow" && compose_state.focus_in_empty_compose()) { message_edit.edit_last_sent_message(); return true; } if (event_name === "down_arrow" && $(":focus").attr("id") === "search_query") { $("#search_query").trigger("blur"); message_scroll_state.set_keyboard_triggered_current_scroll(true); navigate.down(true); } if (event_name === "up_arrow" && $(":focus").attr("id") === "search_query") { $("#search_query").trigger("blur"); message_scroll_state.set_keyboard_triggered_current_scroll(true); navigate.up(); } if ( ((event_name === "down_arrow" || event_name === "page_down" || event_name === "end") && compose_state.focus_in_empty_compose()) || ((event_name === "up_arrow" || event_name === "page_up" || event_name === "home") && compose_state.focus_in_empty_compose(true)) ) { compose_actions.cancel(); // don't return, as we still want it to be picked up by the code below } else { switch (event_name) { case "page_up": $(":focus").caret(0).animate({scrollTop: 0}, "fast"); return true; case "page_down": { // so that it always goes to the end of the text box. const height = util.the($(":focus")).scrollHeight; $(":focus").caret(Infinity).animate({scrollTop: height}, "fast"); return true; } case "search_with_k": // Do nothing; this allows one to use Ctrl+K inside compose. break; case "star_message": // Do nothing; this allows one to use Ctrl+S inside compose. break; case "open_mentions_view": // Do nothing; this allows one to use Ctrl+@ inside compose. break; default: // Let the browser handle the key normally. return false; } } } if (event_name === "left_arrow") { if (overlays.lightbox_open()) { lightbox.prev(); return true; } if (overlays.streams_open()) { stream_settings_ui.toggle_view(event_name); return true; } if (compose_state.focus_in_formatting_buttons()) { // Allow left arrow to scroll the formatting buttons backward return false; } message_edit.edit_last_sent_message(); return true; } if (event_name === "right_arrow") { if (overlays.lightbox_open()) { lightbox.next(); return true; } if (overlays.streams_open()) { stream_settings_ui.toggle_view(event_name); return true; } } // Prevent navigation in the background when the overlays are active. if (overlays.any_active() || is_any_modal_active) { if (event_name === "view_selected_stream" && overlays.streams_open()) { stream_settings_ui.view_stream(); return true; } if ( event_name === "n_key" && overlays.streams_open() && (settings_data.user_can_create_private_streams() || settings_data.user_can_create_public_streams() || settings_data.user_can_create_web_public_streams()) ) { stream_settings_ui.open_create_stream(); return true; } return false; } // Shortcuts that don't require a message let list_of_channel_topics_channel_id; switch (event_name) { case "narrow_private": message_view.show( [ { operator: "is", operand: "dm", }, ], {trigger: "hotkey"}, ); return true; case "query_streams": if (pm_list.is_zoomed_in()) { sidebar_ui.focus_pm_search_filter(); } else if (stream_list.is_zoomed_in()) { sidebar_ui.focus_topic_search_filter(); } else { sidebar_ui.initiate_search(); } return true; case "query_users": if (page_params.is_spectator) { return false; } activity_ui.initiate_search(); return true; case "search": case "search_with_k": search.initiate_search(); return true; case "gear_menu": gear_menu.toggle(); return true; case "set_status": if (page_params.is_spectator) { return false; } user_status_ui.open_user_status_modal(); return true; case "show_shortcuts": // Show keyboard shortcuts page browser_history.go_to_location("keyboard-shortcuts"); return true; case "stream_cycle_backward": message_view.stream_cycle_backward(); return true; case "stream_cycle_forward": message_view.stream_cycle_forward(); return true; case "n_key": message_view.narrow_to_next_topic({ trigger: "next_topic_unread_hotkey", only_followed_topics: false, }); return true; case "narrow_to_next_unread_followed_topic": message_view.narrow_to_next_topic({ trigger: "next_topic_unread_hotkey", only_followed_topics: true, }); return true; case "p_key": message_view.narrow_to_next_pm_string({trigger: "hotkey"}); return true; case "open_recent_view": browser_history.go_to_location("#recent"); return true; case "open_inbox": // Focus search if already in (non-channel) inbox view. if ( inbox_util.is_visible() && !inbox_util.is_channel_view() && !inbox_ui.is_search_focused() ) { inbox_ui.focus_inbox_search(); return true; } browser_history.go_to_location("#inbox"); return true; case "open_starred_message_view": browser_history.go_to_location("#narrow/is/starred"); return true; case "open_mentions_view": browser_history.go_to_location("#narrow/is/mentioned"); return true; case "open_combined_feed": browser_history.go_to_location("#feed"); return true; case "toggle_topic_visibility_policy": if (recent_view_ui.is_in_focus()) { const recent_msg = recent_view_ui.get_focused_row_message(); if (recent_msg?.type === "stream") { user_topics_ui.toggle_topic_visibility_policy(recent_msg); return true; } } if (inbox_ui.is_in_focus()) { return inbox_ui.toggle_topic_visibility_policy(); } if (message_lists.current?.selected_message()) { user_topics_ui.toggle_topic_visibility_policy( message_lists.current.selected_message()!, ); return true; } return false; case "list_of_channel_topics": if (recent_view_ui.is_in_focus()) { const msg = recent_view_ui.get_focused_row_message(); if (msg?.type === "stream") { list_of_channel_topics_channel_id = msg.stream_id; } } if (inbox_ui.is_in_focus()) { // Focus search if already in channel view. if (inbox_util.is_channel_view()) { inbox_ui.focus_inbox_search(); return true; } const msg = inbox_ui.get_focused_row_message(); if (msg?.msg_type === "stream") { list_of_channel_topics_channel_id = msg.stream_id; } } if (message_lists.current !== undefined) { const selected_message = message_lists.current.selected_message(); if (selected_message === undefined) { const only_valid_id = true; list_of_channel_topics_channel_id = narrow_state.stream_id( narrow_state.filter(), only_valid_id, ); } else if (selected_message.type === "stream") { list_of_channel_topics_channel_id = selected_message.stream_id; } } if (list_of_channel_topics_channel_id === undefined) { return false; } browser_history.go_to_location( hash_util.by_channel_topic_list_url(list_of_channel_topics_channel_id), ); return true; } // Shortcuts that are useful with an empty message feed, like opening compose. switch (event_name) { case "reply_message": // 'r': respond to message // Note that you can "Enter" to respond to messages as well, // but that is handled in process_enter_key(). compose_reply.respond_to_message({trigger: "hotkey"}); return true; case "compose": // 'c': compose if (!compose_state.composing()) { compose_actions.start({ message_type: "stream", trigger: "compose_hotkey", keep_composebox_empty: true, }); } return true; case "compose_private_message": if (!compose_state.composing()) { compose_actions.start({ message_type: "private", trigger: "compose_hotkey", keep_composebox_empty: true, }); } return true; case "open_drafts": browser_history.go_to_location("drafts"); return true; case "C_deprecated": deprecated_feature_notice.maybe_show_deprecation_notice("Shift + C"); return true; } // Hotkeys below this point are for the message feed, and so // should only function if the message feed is visible and nonempty. if (message_lists.current === undefined || message_lists.current.visibly_empty()) { return false; } // Shortcuts for navigation and other applications that require a // nonempty message feed but do not depend on the selected message. switch (event_name) { case "down_arrow": case "vim_down": message_scroll_state.set_keyboard_triggered_current_scroll(true); navigate.down(true); // with_centering return true; case "up_arrow": case "vim_up": message_scroll_state.set_keyboard_triggered_current_scroll(true); navigate.up(); return true; case "home": message_scroll_state.set_keyboard_triggered_current_scroll(true); navigate.to_home(); return true; case "end": case "G_end": message_scroll_state.set_keyboard_triggered_current_scroll(true); navigate.to_end(); return true; case "page_up": case "vim_page_up": case "shift_spacebar": message_scroll_state.set_keyboard_triggered_current_scroll(true); navigate.page_up(); return true; case "page_down": case "vim_page_down": case "spacebar": message_scroll_state.set_keyboard_triggered_current_scroll(true); navigate.page_down(); return true; } if ( // Allow UI only features for spectators which they can perform. page_params.is_spectator && ![ "toggle_conversation_view", "show_lightbox", "toggle_sender_info", "edit_message", "copy_with_c", ].includes(event_name) ) { spectators.login_to_access(); return true; } const msg = message_lists.current.selected_message(); assert(msg !== undefined); // Shortcuts that operate on a message switch (event_name) { case "message_actions": return message_actions_popover.toggle_message_actions_menu(msg); case "star_message": starred_messages_ui.toggle_starred_and_update_server(msg); return true; case "toggle_conversation_view": if (narrow_state.narrowed_by_topic_reply()) { // narrow to stream if user is in topic view return do_narrow_action(message_view.narrow_by_recipient); } if (narrow_state.narrowed_by_pm_reply()) { // do nothing if user is in DM view return false; } // else narrow to conversation view (topic / DM) return do_narrow_action(message_view.narrow_by_topic); case "toggle_stream_subscription": deprecated_feature_notice.maybe_show_deprecation_notice("Shift + S"); return true; case "respond_to_author": // 'R': respond to author compose_reply.respond_to_message({reply_type: "personal", trigger: "hotkey pm"}); return true; case "compose_reply_with_mention": // '@': respond to message with mention to author compose_reply.reply_with_mention({trigger: "hotkey"}); return true; case "show_lightbox": lightbox.show_from_selected_message(); return true; case "toggle_sender_info": user_card_popover.toggle_sender_info(); return true; // ':': open reactions to message case "toggle_reactions_popover": { const $row = message_lists.current.selected_row(); const $emoji_icon = $row.find( ".message_controls .emoji-message-control-button-container", ); let emoji_picker_reference; if ( $emoji_icon?.length !== 0 && $emoji_icon.closest(".message_control_button").css("display") !== "none" ) { emoji_picker_reference = util.the($emoji_icon); } else { emoji_picker_reference = util.the($row.find(".message-actions-menu-button")); } emoji_picker.start_picker_for_message_reaction(emoji_picker_reference, msg.id); return true; } case "thumbs_up_emoji": { // '+': reacts with thumbs up emoji on selected message // Use canonical name. const thumbs_up_emoji_code = "1f44d"; const canonical_name = emoji.get_emoji_name(thumbs_up_emoji_code)!; reactions.toggle_emoji_reaction(msg, canonical_name); return true; } case "upvote_first_emoji": { // '=': If the current message has at least one emoji // reaction, toggle out vote on the first one. const message_reactions = reactions.get_message_reactions(msg); if (message_reactions.length === 0) { return true; } const first_reaction = message_reactions[0]; if (!first_reaction) { // If the message has no emoji reactions, do nothing. return true; } const canonical_name = emoji.get_emoji_name(first_reaction.emoji_code); // `canonical_name` will be `undefined` for custom emoji, so we can default // to the `emoji_name`. reactions.toggle_emoji_reaction(msg, canonical_name ?? first_reaction.emoji_name); return true; } case "toggle_message_collapse": condense.toggle_collapse(msg); return true; case "mark_unread": unread_ops.mark_as_unread_from_here(msg.id); return true; case "compose_quote_message": // > : respond to selected message with quote compose_reply.quote_messages({trigger: "hotkey"}); return true; case "compose_forward_message": // < : forward selected message compose_reply.quote_messages({trigger: "hotkey", forward_message: true}); return true; case "edit_message": { const $row = message_lists.current.get_row(msg.id); message_edit.start($row); return true; } case "view_edit_history": { if ( realm.realm_message_edit_history_visibility_policy !== message_edit_history_visibility_policy_values.never.code && (msg.last_edit_timestamp !== undefined || msg.last_moved_timestamp !== undefined) ) { message_edit_history.fetch_and_render_message_history(msg); $("#message-history-overlay .exit-sign").trigger("focus"); return true; } return false; } case "move_message": { if (!message_edit.can_move_message(msg)) { return false; } assert(msg.type === "stream"); void stream_popover.build_move_topic_to_stream_popover( msg.stream_id, msg.topic, false, msg, ); return true; } case "toggle_read_receipts": { read_receipts.show_user_list(msg.id); return true; } case "zoom_to_message_near": { // The following code is essentially equivalent to // `window.location = hashutil.by_conversation_and_time_url(msg)` // but we use `message_view.show` to pass in the `trigger` parameter message_view.narrow_to_message_near(msg, "hotkey"); return true; } case "vim_right": { const url = msg.url; if (url && !msg.locally_echoed) { const $row = message_lists.current.selected_row(); const $message_time = $row.find("a.message-time"); void (async () => { await clipboard_handler.copy_link_to_clipboard(url); show_copied_confirmation(util.the($message_time), { custom_content: $t({defaultMessage: "Message link copied!"}), }); })(); return true; } return false; } } return false; } export function process_keydown(e: JQuery.KeyDownEvent): boolean { activity.set_new_user_input(true); const result = get_keydown_hotkey(e); if (!result) { return false; } if (Array.isArray(result)) { return result.some((hotkey) => process_hotkey(e, hotkey)); } return process_hotkey(e, result); } export function initialize(): void { $(document).on("keydown", (e) => { if (e.key === undefined) { /* Some browsers trigger a 'keydown' event with `key === undefined` on selecting autocomplete suggestion. These are not real keypresses and can be safely ignored. See: https://issues.chromium.org/issues/41425904 */ return; } if (process_keydown(e)) { e.preventDefault(); } }); }