Files
zulip/web/src/hotkey.ts
Prakhar Pratyush e15edc84c2 desktop_notification: Fix error when message deleted while narrowing.
Earlier:
When a user clicked the desktop notification, but before we finished
processing that click, the message deletion got processed.
It resulted in an error.

This commit fixes the bug by taking the user to the conversation
where the now-deleted message had been prior to deletion -
narrow to the message's near view.

Signed-off-by: Prakhar Pratyush <prakhar@zulip.com>
2025-11-04 11:03:36 -08:00

1512 lines
54 KiB
TypeScript

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.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 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 reminders_overlay_ui from "./reminders_overlay_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_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+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+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+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.
const use_event_key =
common.is_printable_ascii(e.key) || KNOWN_NAMED_KEY_ATTRIBUTE_VALUES.has(e.key);
let key = e.key;
if (!use_event_key) {
const code = `${e.shiftKey ? "Shift+" : ""}${e.code}`;
if (CODE_TO_QWERTY_CHAR[code]) {
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 (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;
}
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() && $("#topic_filter_query").is(":focus")) {
topic_list.clear_topic_search(e);
$("#topic_filter_query").trigger("blur");
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.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) {
if (
// Allow all hotkeys except for `down_arrow` and `up_arrow`
// to be handled by the browser.
// Added to handle `schedule-reminder-note` textarea.
processing_text() &&
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()) {
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.href = 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.
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(): 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 ($("#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 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;
}
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);
$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").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") {
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) {
util.the($messagebox.find(".saved_snippets_widget")).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();
}
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(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
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 "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?.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()) {
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);
} 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(
".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.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_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;
}
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;
}
}
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();
}
});
}