diff --git a/tools/test-js-with-node b/tools/test-js-with-node index 8045d8f875..a55b0d8357 100755 --- a/tools/test-js-with-node +++ b/tools/test-js-with-node @@ -113,6 +113,7 @@ EXEMPT_FILES = make_set( "web/src/loading.ts", "web/src/local_message.js", "web/src/localstorage.ts", + "web/src/message_actions_popover.js", "web/src/message_edit.js", "web/src/message_edit_history.js", "web/src/message_events.js", diff --git a/web/src/hotkey.js b/web/src/hotkey.js index d003848c6c..dcc3096282 100644 --- a/web/src/hotkey.js +++ b/web/src/hotkey.js @@ -24,6 +24,7 @@ import * as hotspots from "./hotspots"; import * as inbox_ui from "./inbox_ui"; import * as lightbox from "./lightbox"; import * as list_util from "./list_util"; +import * as message_actions_popover from "./message_actions_popover"; import * as message_edit from "./message_edit"; import * as message_edit_history from "./message_edit_history"; import * as message_lists from "./message_lists"; @@ -1007,7 +1008,7 @@ export function process_hotkey(e, hotkey) { // Shortcuts that operate on a message switch (event_name) { case "message_actions": - return popover_menus.toggle_message_actions_menu(msg); + return message_actions_popover.toggle_message_actions_menu(msg); case "star_message": starred_messages_ui.toggle_starred_and_update_server(msg); return true; diff --git a/web/src/message_actions_popover.js b/web/src/message_actions_popover.js new file mode 100644 index 0000000000..1f94900297 --- /dev/null +++ b/web/src/message_actions_popover.js @@ -0,0 +1,216 @@ +import ClipboardJS from "clipboard"; +import $ from "jquery"; + +import render_actions_popover_content from "../templates/actions_popover_content.hbs"; + +import * as blueslip from "./blueslip"; +import * as compose_actions from "./compose_actions"; +import * as condense from "./condense"; +import {show_copied_confirmation} from "./copied_tooltip"; +import * as emoji_picker from "./emoji_picker"; +import * as message_edit from "./message_edit"; +import * as message_lists from "./message_lists"; +import * as message_viewport from "./message_viewport"; +import * as popover_menus from "./popover_menus"; +import * as popover_menus_data from "./popover_menus_data"; +import * as read_receipts from "./read_receipts"; +import * as rows from "./rows"; +import * as stream_popover from "./stream_popover"; +import {parse_html} from "./ui_util"; +import * as unread_ops from "./unread_ops"; + +let message_actions_popover_keyboard_toggle = false; + +function get_action_menu_menu_items() { + const $current_actions_popover_elem = $("[data-tippy-root] .actions_popover"); + if (!$current_actions_popover_elem) { + blueslip.error("Trying to get menu items when action popover is closed."); + return undefined; + } + + return $current_actions_popover_elem.find("li:not(.divider):visible a"); +} + +function focus_first_action_popover_item() { + // For now I recommend only calling this when the user opens the menu with a hotkey. + // Our popup menus act kind of funny when you mix keyboard and mouse. + const $items = get_action_menu_menu_items(); + popover_menus.focus_first_popover_item($items); +} + +export function toggle_message_actions_menu(message) { + if (message.locally_echoed || message_edit.is_editing(message.id)) { + // Don't open the popup for locally echoed messages for now. + // It creates bugs with things like keyboard handlers when + // we get the server response. + // We also suppress the popup for messages in an editing state, + // including previews, when a user tries to reach them from the + // keyboard. + return true; + } + + message_viewport.maybe_scroll_to_show_message_top(); + const $popover_reference = $(".selected_message .actions_hover .message-actions-menu-button"); + message_actions_popover_keyboard_toggle = true; + $popover_reference.trigger("click"); + return true; +} + +export function initialize() { + popover_menus.register_popover_menu(".actions_hover .message-actions-menu-button", { + // 320px is our minimum supported width for mobile. We will allow the value to flex + // to a max of 350px but we shouldn't make the popover wider than this. + maxWidth: "min(max(320px, 100vw), 350px)", + placement: "bottom", + popperOptions: { + modifiers: [ + { + // The placement is set to bottom, but if that placement does not fit, + // the opposite top placement will be used. + name: "flip", + options: { + fallbackPlacements: ["top", "left"], + }, + }, + ], + }, + onShow(instance) { + popover_menus.on_show_prep(instance); + const $row = $(instance.reference).closest(".message_row"); + const message_id = rows.id($row); + message_lists.current.select_id(message_id); + const args = popover_menus_data.get_actions_popover_content_context(message_id); + instance.setContent(parse_html(render_actions_popover_content(args))); + $row.addClass("has_actions_popover"); + }, + onMount(instance) { + if (message_actions_popover_keyboard_toggle) { + focus_first_action_popover_item(); + message_actions_popover_keyboard_toggle = false; + } + popover_menus.popover_instances.message_actions = instance; + + // We want click events to propagate to `instance` so that + // instance.hide gets called. + const $popper = $(instance.popper); + $popper.one("click", ".respond_button", (e) => { + // Arguably, we should fetch the message ID to respond to from + // e.target, but that should always be the current selected + // message in the current message list (and + // compose_actions.respond_to_message doesn't take a message + // argument). + compose_actions.quote_and_reply({trigger: "popover respond"}); + e.preventDefault(); + e.stopPropagation(); + instance.hide(); + }); + + $popper.one("click", ".popover_edit_message, .popover_view_source", (e) => { + const message_id = $(e.currentTarget).data("message-id"); + const $row = message_lists.current.get_row(message_id); + message_edit.start($row); + e.preventDefault(); + e.stopPropagation(); + instance.hide(); + }); + + $popper.one("click", ".popover_move_message", (e) => { + const message_id = $(e.currentTarget).data("message-id"); + const message = message_lists.current.get(message_id); + stream_popover.build_move_topic_to_stream_popover( + message.stream_id, + message.topic, + false, + message, + ); + e.preventDefault(); + e.stopPropagation(); + instance.hide(); + }); + + $popper.one("click", ".mark_as_unread", (e) => { + const message_id = $(e.currentTarget).data("message-id"); + unread_ops.mark_as_unread_from_here(message_id); + e.preventDefault(); + e.stopPropagation(); + instance.hide(); + }); + + $popper.one("click", ".popover_toggle_collapse", (e) => { + const message_id = $(e.currentTarget).data("message-id"); + const $row = message_lists.current.get_row(message_id); + const message = message_lists.current.get(rows.id($row)); + if ($row) { + if (message.collapsed) { + condense.uncollapse($row); + } else { + condense.collapse($row); + } + } + e.preventDefault(); + e.stopPropagation(); + instance.hide(); + }); + + $popper.one("click", ".rehide_muted_user_message", (e) => { + const message_id = $(e.currentTarget).data("message-id"); + const $row = message_lists.current.get_row(message_id); + const message = message_lists.current.get(rows.id($row)); + const message_container = message_lists.current.view.message_containers.get( + message.id, + ); + if ($row && !message_container.is_hidden) { + message_lists.current.view.hide_revealed_message(message_id); + } + e.preventDefault(); + e.stopPropagation(); + instance.hide(); + }); + + $popper.one("click", ".view_read_receipts", (e) => { + const message_id = $(e.currentTarget).data("message-id"); + read_receipts.show_user_list(message_id); + e.preventDefault(); + e.stopPropagation(); + instance.hide(); + }); + + $popper.one("click", ".delete_message", (e) => { + const message_id = $(e.currentTarget).data("message-id"); + message_edit.delete_message(message_id); + e.preventDefault(); + e.stopPropagation(); + instance.hide(); + }); + + $popper.one("click", ".reaction_button", (e) => { + const message_id = $(e.currentTarget).data("message-id"); + // Don't propagate the click event since `toggle_emoji_popover` opens a + // emoji_picker which we don't want to hide after actions popover is hidden. + e.stopPropagation(); + e.preventDefault(); + emoji_picker.toggle_emoji_popover(instance.reference.parentElement, message_id, { + placement: "bottom", + }); + instance.hide(); + }); + + new ClipboardJS($popper.find(".copy_link")[0]).on("success", () => { + show_copied_confirmation($(instance.reference).closest(".message_controls")[0]); + setTimeout(() => { + // The Clipboard library works by focusing to a hidden textarea. + // We unfocus this so keyboard shortcuts, etc., will work again. + $(":focus").trigger("blur"); + }, 0); + instance.hide(); + }); + }, + onHidden(instance) { + const $row = $(instance.reference).closest(".message_row"); + $row.removeClass("has_actions_popover"); + instance.destroy(); + popover_menus.popover_instances.message_actions = undefined; + message_actions_popover_keyboard_toggle = false; + }, + }); +} diff --git a/web/src/popover_menus.js b/web/src/popover_menus.js index a81576cd14..67ba880eb0 100644 --- a/web/src/popover_menus.js +++ b/web/src/popover_menus.js @@ -6,7 +6,6 @@ import ClipboardJS from "clipboard"; import $ from "jquery"; import tippy, {delegate} from "tippy.js"; -import render_actions_popover_content from "../templates/actions_popover_content.hbs"; import render_all_messages_sidebar_actions from "../templates/all_messages_sidebar_actions.hbs"; import render_change_visibility_policy_popover from "../templates/change_visibility_policy_popover.hbs"; import render_compose_control_buttons_popover from "../templates/compose_control_buttons_popover.hbs"; @@ -21,22 +20,16 @@ import * as blueslip from "./blueslip"; import * as channel from "./channel"; import * as common from "./common"; import * as compose_actions from "./compose_actions"; -import * as condense from "./condense"; import * as confirm_dialog from "./confirm_dialog"; -import {show_copied_confirmation} from "./copied_tooltip"; import * as drafts from "./drafts"; -import * as emoji_picker from "./emoji_picker"; import * as giphy from "./giphy"; import {$t_html} from "./i18n"; import * as message_edit from "./message_edit"; -import * as message_lists from "./message_lists"; -import * as message_viewport from "./message_viewport"; import * as narrow_state from "./narrow_state"; import * as overlays from "./overlays"; import {page_params} from "./page_params"; import * as popover_menus_data from "./popover_menus_data"; import * as popovers from "./popovers"; -import * as read_receipts from "./read_receipts"; import * as rows from "./rows"; import * as starred_messages from "./starred_messages"; import * as starred_messages_ui from "./starred_messages_ui"; @@ -46,8 +39,6 @@ import * as unread_ops from "./unread_ops"; import {user_settings} from "./user_settings"; import * as user_topics from "./user_topics"; -let message_actions_popover_keyboard_toggle = false; - // On mobile web, opening the keyboard can trigger a resize event // (which in turn can trigger a scroll event). This will have the // side effect of closing popovers, which we don't want. So we @@ -104,23 +95,6 @@ export function focus_first_popover_item($items, index = 0) { $items.eq(index).expectOne().trigger("focus"); } -function get_action_menu_menu_items() { - const $current_actions_popover_elem = $("[data-tippy-root] .actions_popover"); - if (!$current_actions_popover_elem) { - blueslip.error("Trying to get menu items when action popover is closed."); - return undefined; - } - - return $current_actions_popover_elem.find("li:not(.divider):visible a"); -} - -function focus_first_action_popover_item() { - // For now I recommend only calling this when the user opens the menu with a hotkey. - // Our popup menus act kind of funny when you mix keyboard and mouse. - const $items = get_action_menu_menu_items(); - focus_first_popover_item($items); -} - export function sidebar_menu_instance_handle_keyboard(instance, key) { const items = get_popover_items_for_instance(instance); popover_items_handle_keyboard(key, items); @@ -309,24 +283,6 @@ export function register_popover_menu(target, popover_props) { }); } -export function toggle_message_actions_menu(message) { - if (message.locally_echoed || message_edit.is_editing(message.id)) { - // Don't open the popup for locally echoed messages for now. - // It creates bugs with things like keyboard handlers when - // we get the server response. - // We also suppress the popup for messages in an editing state, - // including previews, when a user tries to reach them from the - // keyboard. - return true; - } - - message_viewport.maybe_scroll_to_show_message_top(); - const $popover_reference = $(".selected_message .actions_hover .message-actions-menu-button"); - message_actions_popover_keyboard_toggle = true; - $popover_reference.trigger("click"); - return true; -} - export function initialize() { // compose box buttons popover shown on mobile widths. // We want this click event to propagate and hide other popovers @@ -656,163 +612,6 @@ export function initialize() { }, }); - register_popover_menu(".actions_hover .message-actions-menu-button", { - // 320px is our minimum supported width for mobile. We will allow the value to flex - // to a max of 350px but we shouldn't make the popover wider than this. - maxWidth: "min(max(320px, 100vw), 350px)", - placement: "bottom", - popperOptions: { - modifiers: [ - { - // The placement is set to bottom, but if that placement does not fit, - // the opposite top placement will be used. - name: "flip", - options: { - fallbackPlacements: ["top", "left"], - }, - }, - ], - }, - onShow(instance) { - on_show_prep(instance); - const $row = $(instance.reference).closest(".message_row"); - const message_id = rows.id($row); - message_lists.current.select_id(message_id); - const args = popover_menus_data.get_actions_popover_content_context(message_id); - instance.setContent(parse_html(render_actions_popover_content(args))); - $row.addClass("has_actions_popover"); - }, - onMount(instance) { - if (message_actions_popover_keyboard_toggle) { - focus_first_action_popover_item(); - message_actions_popover_keyboard_toggle = false; - } - popover_instances.message_actions = instance; - - // We want click events to propagate to `instance` so that - // instance.hide gets called. - const $popper = $(instance.popper); - $popper.one("click", ".respond_button", (e) => { - // Arguably, we should fetch the message ID to respond to from - // e.target, but that should always be the current selected - // message in the current message list (and - // compose_actions.respond_to_message doesn't take a message - // argument). - compose_actions.quote_and_reply({trigger: "popover respond"}); - e.preventDefault(); - e.stopPropagation(); - instance.hide(); - }); - - $popper.one("click", ".popover_edit_message, .popover_view_source", (e) => { - const message_id = $(e.currentTarget).data("message-id"); - const $row = message_lists.current.get_row(message_id); - message_edit.start($row); - e.preventDefault(); - e.stopPropagation(); - instance.hide(); - }); - - $popper.one("click", ".popover_move_message", (e) => { - const message_id = $(e.currentTarget).data("message-id"); - const message = message_lists.current.get(message_id); - stream_popover.build_move_topic_to_stream_popover( - message.stream_id, - message.topic, - false, - message, - ); - e.preventDefault(); - e.stopPropagation(); - instance.hide(); - }); - - $popper.one("click", ".mark_as_unread", (e) => { - const message_id = $(e.currentTarget).data("message-id"); - unread_ops.mark_as_unread_from_here(message_id); - e.preventDefault(); - e.stopPropagation(); - instance.hide(); - }); - - $popper.one("click", ".popover_toggle_collapse", (e) => { - const message_id = $(e.currentTarget).data("message-id"); - const $row = message_lists.current.get_row(message_id); - const message = message_lists.current.get(rows.id($row)); - if ($row) { - if (message.collapsed) { - condense.uncollapse($row); - } else { - condense.collapse($row); - } - } - e.preventDefault(); - e.stopPropagation(); - instance.hide(); - }); - - $popper.one("click", ".rehide_muted_user_message", (e) => { - const message_id = $(e.currentTarget).data("message-id"); - const $row = message_lists.current.get_row(message_id); - const message = message_lists.current.get(rows.id($row)); - const message_container = message_lists.current.view.message_containers.get( - message.id, - ); - if ($row && !message_container.is_hidden) { - message_lists.current.view.hide_revealed_message(message_id); - } - e.preventDefault(); - e.stopPropagation(); - instance.hide(); - }); - - $popper.one("click", ".view_read_receipts", (e) => { - const message_id = $(e.currentTarget).data("message-id"); - read_receipts.show_user_list(message_id); - e.preventDefault(); - e.stopPropagation(); - instance.hide(); - }); - - $popper.one("click", ".delete_message", (e) => { - const message_id = $(e.currentTarget).data("message-id"); - message_edit.delete_message(message_id); - e.preventDefault(); - e.stopPropagation(); - instance.hide(); - }); - - $popper.one("click", ".reaction_button", (e) => { - const message_id = $(e.currentTarget).data("message-id"); - // Don't propagate the click event since `toggle_emoji_popover` opens a - // emoji_picker which we don't want to hide after actions popover is hidden. - e.stopPropagation(); - e.preventDefault(); - emoji_picker.toggle_emoji_popover(instance.reference.parentElement, message_id, { - placement: "bottom", - }); - instance.hide(); - }); - - new ClipboardJS($popper.find(".copy_link")[0]).on("success", () => { - show_copied_confirmation($(instance.reference).closest(".message_controls")[0]); - setTimeout(() => { - // The Clipboard library works by focusing to a hidden textarea. - // We unfocus this so keyboard shortcuts, etc., will work again. - $(":focus").trigger("blur"); - }, 0); - instance.hide(); - }); - }, - onHidden(instance) { - const $row = $(instance.reference).closest(".message_row"); - $row.removeClass("has_actions_popover"); - instance.destroy(); - popover_instances.message_actions = undefined; - message_actions_popover_keyboard_toggle = false; - }, - }); - // Starred messages popover register_popover_menu(".starred-messages-sidebar-menu-icon", { ...left_sidebar_tippy_options, diff --git a/web/src/ui_init.js b/web/src/ui_init.js index b66bd69c95..aff4fea390 100644 --- a/web/src/ui_init.js +++ b/web/src/ui_init.js @@ -49,6 +49,7 @@ import * as linkifiers from "./linkifiers"; import {localstorage} from "./localstorage"; import * as markdown from "./markdown"; import * as markdown_config from "./markdown_config"; +import * as message_actions_popover from "./message_actions_popover"; import * as message_edit_history from "./message_edit_history"; import * as message_fetch from "./message_fetch"; import * as message_list from "./message_list"; @@ -526,6 +527,7 @@ export function initialize_everything() { // This populates data for scheduled messages. scheduled_messages.initialize(scheduled_messages_params); popover_menus.initialize(); + message_actions_popover.initialize(); scheduled_messages_popover.initialize(); realm_user_settings_defaults.initialize(realm_settings_defaults_params); diff --git a/web/tests/hotkey.test.js b/web/tests/hotkey.test.js index a35f3b437b..12c9fdadcb 100644 --- a/web/tests/hotkey.test.js +++ b/web/tests/hotkey.test.js @@ -45,6 +45,7 @@ const gear_menu = mock_esm("../src/gear_menu", { }); const lightbox = mock_esm("../src/lightbox"); const list_util = mock_esm("../src/list_util"); +const message_actions_popover = mock_esm("../src/message_actions_popover"); const message_edit = mock_esm("../src/message_edit"); const message_lists = mock_esm("../src/message_lists"); const user_topics_ui = mock_esm("../src/user_topics_ui"); @@ -79,9 +80,6 @@ const popovers = mock_esm("../src/user_card_popover", { is_open: () => false, }, }); -const popover_menus = mock_esm("../src/popover_menus", { - get_visible_instance: () => undefined, -}); const reactions = mock_esm("../src/reactions"); const search = mock_esm("../src/search"); const settings_data = mock_esm("../src/settings_data"); @@ -385,7 +383,7 @@ run_test("misc", ({override}) => { assert_mapping("k", navigate, "up"); assert_mapping("K", navigate, "page_up"); assert_mapping("u", popovers, "toggle_sender_info"); - assert_mapping("i", popover_menus, "toggle_message_actions_menu"); + assert_mapping("i", message_actions_popover, "toggle_message_actions_menu"); assert_mapping(":", emoji_picker, "toggle_emoji_popover", true); assert_mapping(">", compose_actions, "quote_and_reply"); assert_mapping("e", message_edit, "start");