/
githubmirror
/
zulip
Обзор
Документация
Войти
/
githubmirror
/
zulip
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
11.0-dev
web/src/hotkey.js
1 350 строк
49 KB
apoorvapendse
copy: Use a global copy event listener.
19 мар 2025, 20:07
19 мар 2025, 20:07
11028d5
Код
Авторство
О чём код?
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 color_picker_popover from "./color_picker_popover.ts"; import * as common from "./common.ts"; import * as compose from "./compose.js"; 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.js"; import * as compose_state from "./compose_state.ts"; import * as compose_textarea from "./compose_textarea.ts"; import * as condense from "./condense.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 giphy from "./giphy.ts"; import * as hash_util from "./hash_util.ts"; import * as hashchange from "./hashchange.ts"; import * as inbox_ui from "./inbox_ui.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 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_topics_ui from "./user_topics_ui.ts"; function do_narrow_action(action) { 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", ]); // 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_shift_mappings = { // these can be triggered by Shift + key only 9: {name: "shift_tab", message_view_only: false}, // Tab 32: {name: "shift_spacebar", message_view_only: true}, // space bar 37: {name: "left_arrow", message_view_only: false}, // left arrow 39: {name: "right_arrow", message_view_only: false}, // right arrow 38: {name: "up_arrow", message_view_only: false}, // up arrow 40: {name: "down_arrow", message_view_only: false}, // down arrow 72: {name: "view_edit_history", message_view_only: true}, // 'H' 78: {name: "narrow_to_next_unread_followed_topic", message_view_only: false}, // 'N' 86: {name: "toggle_read_receipts", message_view_only: true}, // 'V' }; const keydown_unshift_mappings = { // these can be triggered by key only (without Shift) 9: {name: "tab", message_view_only: false}, // Tab 27: {name: "escape", message_view_only: false}, // Esc 32: {name: "spacebar", message_view_only: true}, // space bar 33: {name: "page_up", message_view_only: true}, // PgUp 34: {name: "page_down", message_view_only: true}, // PgDn 35: {name: "end", message_view_only: true}, // End 36: {name: "home", message_view_only: true}, // Home 37: {name: "left_arrow", message_view_only: false}, // left arrow 39: {name: "right_arrow", message_view_only: false}, // right arrow 38: {name: "up_arrow", message_view_only: false}, // up arrow 40: {name: "down_arrow", message_view_only: false}, // down arrow }; const keydown_ctrl_mappings = { 219: {name: "escape", message_view_only: false}, // '[' }; const keydown_cmd_or_ctrl_mappings = { 13: {name: "action_with_enter", message_view_only: true}, // 'Enter' 67: {name: "copy_with_c", message_view_only: false}, // 'C' 75: {name: "search_with_k", message_view_only: false}, // 'K' 83: {name: "star_message", message_view_only: true}, // 'S' 190: {name: "narrow_to_compose_target", message_view_only: true}, // '.' 222: {name: "open_saved_snippet_dropdown", message_view_only: true}, // ''' }; const keydown_alt_mappings = { 80: {name: "toggle_compose_preview", message_view_only: true}, // 'P' }; const keydown_either_mappings = { // these can be triggered by key or Shift + key // Note that codes for letters are still case sensitive! // // 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. 8: {name: "backspace", message_view_only: true}, // Backspace 13: {name: "enter", message_view_only: false}, // Enter 46: {name: "delete", message_view_only: false}, // Delete }; const keypress_mappings = { 42: {name: "open_starred_message_view", message_view_only: true}, // '*' 43: {name: "thumbs_up_emoji", message_view_only: true}, // '+' 61: {name: "upvote_first_emoji", message_view_only: true}, // '=' 45: {name: "toggle_message_collapse", message_view_only: true}, // '-' 47: {name: "search", message_view_only: false}, // '/' 58: {name: "toggle_reactions_popover", message_view_only: true}, // ':' 60: {name: "compose_forward_message", message_view_only: true}, // '<' 62: {name: "compose_quote_message", message_view_only: true}, // '>' 63: {name: "show_shortcuts", message_view_only: false}, // '?' 64: {name: "compose_reply_with_mention", message_view_only: true}, // '@' 65: {name: "stream_cycle_backward", message_view_only: true}, // 'A' 67: {name: "C_deprecated", message_view_only: true}, // 'C' 68: {name: "stream_cycle_forward", message_view_only: true}, // 'D' 71: {name: "G_end", message_view_only: true}, // 'G' 73: {name: "open_inbox", message_view_only: true}, // 'I' 74: {name: "vim_page_down", message_view_only: true}, // 'J' 75: {name: "vim_page_up", message_view_only: true}, // 'K' 77: {name: "toggle_topic_visibility_policy", message_view_only: true}, // 'M' 80: {name: "narrow_private", message_view_only: true}, // 'P' 82: {name: "respond_to_author", message_view_only: true}, // 'R' 83: {name: "toggle_stream_subscription", message_view_only: true}, // 'S' 85: {name: "mark_unread", message_view_only: true}, // 'U' 86: {name: "view_selected_stream", message_view_only: false}, // 'V' // The shortcut "a" dates from when this was called "All messages". 97: {name: "open_combined_feed", message_view_only: true}, // 'a' 99: {name: "compose", message_view_only: true}, // 'c' 100: {name: "open_drafts", message_view_only: true}, // 'd' 101: {name: "edit_message", message_view_only: true}, // 'e' 103: {name: "gear_menu", message_view_only: true}, // 'g' 104: {name: "vim_left", message_view_only: true}, // 'h' 105: {name: "message_actions", message_view_only: true}, // 'i' 106: {name: "vim_down", message_view_only: true}, // 'j' 107: {name: "vim_up", message_view_only: true}, // 'k' 108: {name: "vim_right", message_view_only: true}, // 'l' 109: {name: "move_message", message_view_only: true}, // 'm' 110: {name: "n_key", message_view_only: false}, // 'n' 112: {name: "p_key", message_view_only: false}, // 'p' 113: {name: "query_streams", message_view_only: true}, // 'q' 114: {name: "reply_message", message_view_only: true}, // 'r' 115: {name: "toggle_conversation_view", message_view_only: true}, // 's' 116: {name: "open_recent_view", message_view_only: true}, // 't' 117: {name: "toggle_sender_info", message_view_only: true}, // 'u' 118: {name: "show_lightbox", message_view_only: true}, // 'v' 119: {name: "query_users", message_view_only: true}, // 'w' 120: {name: "compose_private_message", message_view_only: true}, // 'x' 122: {name: "zoom_to_message_near", message_view_only: true}, // 'z' }; export function get_keydown_hotkey(e) { let hotkey; if (e.altKey) { hotkey = keydown_alt_mappings[e.which]; if (hotkey) { return hotkey; } return undefined; } if (e.ctrlKey && !e.shiftKey) { hotkey = keydown_ctrl_mappings[e.which]; if (hotkey) { return hotkey; } } const isCmdOrCtrl = common.has_mac_keyboard() ? e.metaKey : e.ctrlKey; if (isCmdOrCtrl && !e.shiftKey) { hotkey = keydown_cmd_or_ctrl_mappings[e.which]; if (hotkey) { return hotkey; } return undefined; } else if (e.metaKey || e.ctrlKey) { return undefined; } if (e.shiftKey) { hotkey = keydown_shift_mappings[e.which]; if (hotkey) { return hotkey; } } if (!e.shiftKey) { hotkey = keydown_unshift_mappings[e.which]; if (hotkey) { return hotkey; } } return keydown_either_mappings[e.which]; } export function get_keypress_hotkey(e) { if (e.metaKey || e.ctrlKey || e.altKey) { return undefined; } return keypress_mappings[e.which]; } export let processing_text = () => { 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) { 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. export function process_escape_key(e) { 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 (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_and_hide_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; } if (giphy.is_popped_from_edit_message()) { giphy.focus_current_edit_message(); // Hide after setting focus so that `edit_message_id` is // still set in giphy. giphy.hide_giphy_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() && $("#filter-topic-input").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 giphy popover was open using compose box. // Hide GIPHY popover if it's open. if (!giphy.is_popped_from_edit_message() && giphy.hide_giphy_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.which === 219) { 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) { 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) { 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. export function process_enter_key(e) { 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`. 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()) { if (stream_list.searching()) { // This is sort of funny behavior, but I think // the intention is that we want it super easy // to close stream search. stream_list.clear_and_hide_search(); return true; } // 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()) { drafts_overlay_ui.handle_keyboard_events("enter"); return true; } if (overlays.scheduled_messages_open()) { scheduled_messages_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. 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 ($("#preview_message_area").is(":visible")) { 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.supports_collapsing_recipients()) { assert(message_lists.current !== undefined); const message = message_lists.current.selected_message(); if (message === undefined) { // No selected message. return false; } window.location = 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() { if ($("#preview_message_area").is(":visible")) { 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() { // 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. let $message_edit_form; const $focused_message_edit_content = $(".message_edit_content:focus"); if ($focused_message_edit_content.length > 0) { $message_edit_form = $focused_message_edit_content.closest(".message_edit_form"); // Open message edit forms either have a save button or a close button, but not both. $message_edit_form.find(".message_edit_save,.message_edit_close").trigger("focus"); return true; } const $focused_message_edit_save = $(".message_edit_save:focus"); if ($focused_message_edit_save.length > 0) { $message_edit_form = $focused_message_edit_save.closest(".message_edit_form"); $message_edit_form.find(".message_edit_cancel").trigger("focus"); return true; } if (emoji_picker.is_open()) { return emoji_picker.navigate("tab"); } return false; } export function process_shift_tab_key() { // 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 ($("#compose-send-button").is(":focus")) { // Shift-Tab: go back to content textarea and restore // cursor position. compose_textarea.restore_compose_cursor(); return true; } // Shift-Tabbing from the edit message cancel button takes you to save. if ($(".message_edit_cancel:focus").length > 0) { $(".message_edit_save").trigger("focus"); return true; } // Shift-Tabbing from the edit message save button takes you to the content. const $focused_message_edit_save = $(".message_edit_save:focus"); if ($focused_message_edit_save.length > 0) { $focused_message_edit_save .closest(".message_edit_form") .find(".message_edit_content") .trigger("focus"); 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 or keypress event. // // Returns true if we handled it, false if the browser should. export function process_hotkey(e, hotkey) { 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()) { 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(e); 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.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; } 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.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); $last_focused_compose_type_input.trigger("focus"); } else { message_edit.show_preview_area($last_focused_compose_type_input); } return true; } if (compose_state.composing()) { if ($("#compose .markdown_preview").is(":visible")) { compose.show_preview_area(); } else { compose.clear_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") { 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) { $messagebox.find(".saved_snippets_widget")[0].click(); } } 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(true); } 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 = $(":focus")[0].scrollHeight; $(":focus") .caret(Number.POSITIVE_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; default: // Let the browser handle the key normally. return false; } } } if (event_name === "left_arrow") { if (overlays.lightbox_open()) { lightbox.prev(); return true; } else if (overlays.streams_open()) { stream_settings_ui.toggle_view(event_name); return true; } else 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; } else 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 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()) { pm_list.focus_pm_search_filter(); } else if (stream_list.is_zoomed_in()) { topic_list.focus_topic_search_filter(); } else { stream_list.initiate_search(); } return true; case "query_users": 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 "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: "hotkey", only_followed_topics: false}); return true; case "narrow_to_next_unread_followed_topic": message_view.narrow_to_next_topic({trigger: "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": 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_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 !== undefined && recent_msg.type === "stream") { user_topics_ui.toggle_topic_visibility_policy(recent_msg); return true; } return false; } if (inbox_ui.is_in_focus()) { return inbox_ui.toggle_topic_visibility_policy(); } } // 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"].includes(event_name) ) { spectators.login_to_access(); return true; } const msg = message_lists.current.selected_message(); // 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); } else 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(".emoji-message-control-button-container"); let emoji_picker_reference; if ($emoji_icon.is(":visible")) { emoji_picker_reference = $emoji_icon[0]; } else { emoji_picker_reference = $row.find(".message-actions-menu-button")[0]; } emoji_picker.toggle_emoji_popover(emoji_picker_reference, msg.id, { placement: "bottom", }); 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_topic_visibility_policy": user_topics_ui.toggle_topic_visibility_policy(msg); 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_message({trigger: "hotkey"}); return true; case "compose_forward_message": // < : forward selected message compose_reply.quote_message({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 ) { 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; } 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 switch (msg.type) { case "private": message_view.show( [ {operator: "dm", operand: msg.reply_to}, {operator: "near", operand: msg.id}, ], {trigger: "hotkey"}, ); return true; case "stream": message_view.show( [ { operator: "channel", operand: msg.stream_id.toString(), }, {operator: "topic", operand: msg.topic}, {operator: "near", operand: msg.id}, ], {trigger: "hotkey"}, ); return true; } } } return false; } /* We register both a keydown and a keypress function because we want to intercept PgUp/PgDn, Esc, etc, and process them as they happen on the keyboard. However, if we processed letters/numbers in keydown, we wouldn't know what the case of the letters were. We want case-sensitive hotkeys (such as in the case of r vs R) so we bail in .keydown if the event is a letter or number and instead just let keypress go for it. */ export function process_keydown(e) { activity.set_new_user_input(true); const hotkey = get_keydown_hotkey(e); if (!hotkey) { return false; } return process_hotkey(e, hotkey); } export function process_keypress(e) { const hotkey = get_keypress_hotkey(e); if (!hotkey) { return false; } return process_hotkey(e, hotkey); } export function initialize() { $(document).on("keydown", (e) => { if (process_keydown(e)) { e.preventDefault(); } }); $(document).on("keypress", (e) => { if (process_keypress(e)) { e.preventDefault(); } }); }