"use strict"; const assert = require("node:assert/strict"); const {make_user_group} = require("./lib/example_group.cjs"); const {make_realm} = require("./lib/example_realm.cjs"); const {make_user} = require("./lib/example_user.cjs"); const {mock_esm, zrequire} = require("./lib/namespace.cjs"); const {run_test} = require("./lib/test.cjs"); const {$} = require("./lib/zjquery.cjs"); const {page_params} = require("./lib/zpage_params.cjs"); const {Filter} = zrequire("filter"); const {MessageList} = zrequire("message_list"); const message_lists = zrequire("message_lists"); const message_parser = mock_esm("../src/message_parser", { message_has_link_preview: () => false, }); const popover_menus_data = zrequire("popover_menus_data"); const people = zrequire("people"); const user_groups = zrequire("user_groups"); const {MessageListData} = zrequire("message_list_data"); const {set_current_user, set_realm} = zrequire("state_data"); const settings_config = zrequire("settings_config"); const noop = function () {}; // Define MessageList stuff function MessageListView() { return { maybe_rerender: noop, append: noop, prepend: noop, clear_rendering_state: noop, get_row: () => ({ find(selector) { if (selector === ".message_content") { return { hasClass(class_name) { assert.equal(class_name, "condensed"); return false; }, }; } assert.equal(selector, ".message_controls .reaction_button"); return { length: 1, css(property) { assert.equal(property, "display"); return "none"; }, }; }, }), message_containers: new Map(), }; } mock_esm("../src/message_list_view", { MessageListView, }); mock_esm("../src/ui_util", { listener_for_preferred_color_scheme_change: noop, }); mock_esm("../src/hash_util", { by_conversation_and_time_url: () => "conversation_and_time_url", }); mock_esm("../src/stream_data", { is_subscribed: () => true, is_stream_archived_by_id: () => false, get_sub_by_id: () => noop, user_can_move_messages_within_channel: () => true, is_empty_topic_only_channel: () => false, }); mock_esm("../src/group_permission_settings", { get_group_permission_setting_config() { return { allow_everyone_group: false, }; }, }); mock_esm("../src/timerender", { display_time_zone: "UTC", get_localized_date_or_time_for_format(date) { // Return a simple date string for testing. const d = new Date(date); const months = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; return `${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`; }, }); const current_user = {}; set_current_user(current_user); const realm = make_realm(); set_realm(realm); // Define test users const mike = make_user({ user_id: 1000, full_name: "Test Mike", email: "mike@example.com", }); const bob = make_user({ user_id: 2000, full_name: "Test Bob", email: "bob@example.com", }); const me = make_user({ user_id: 999, full_name: "Test Myself", email: "me@example.com", }); const everyone = make_user_group({ name: "role:everyone", id: 2, members: new Set([999, 1000, 2000]), is_system_group: true, direct_subgroup_ids: new Set(), }); user_groups.initialize({realm_user_groups: [everyone]}); // Helper functions: function add_initialize_users() { // Initialize people people.init(); // Add users people.add_active_user(mike); people.add_active_user(bob); people.add_active_user(me); // Initialize current user people.initialize_current_user(me.user_id); } function init_message_list() { const filter = new Filter([]); const list = new MessageList({ data: new MessageListData({ excludes_muted_topics: false, filter, }), }); assert.equal(list.empty(), true); return list; } // Append message to message_list, also add container to message_lists function add_message_with_view(list, messages) { list.append(messages, true); for (const message of messages) { message_lists.current.view.message_containers.set(message.id, { is_hidden: message.is_hidden, }); } } // Function sets page parameters with no time constraints on editing the message. // User is assumed to not be an admin. function set_page_params_no_edit_restrictions({override}) { page_params.is_spectator = false; override(realm, "realm_allow_message_editing", true); override(realm, "realm_message_content_edit_limit_seconds", null); override( realm, "realm_message_edit_history_visibility_policy", settings_config.message_edit_history_visibility_policy_values.always.code, ); override(realm, "realm_message_content_delete_limit_seconds", null); override(realm, "realm_enable_read_receipts", true); override(realm, "realm_move_messages_within_stream_limit_seconds", null); } // Test init function function test(label, f) { run_test(label, (helpers) => { // Stubs for calculate_timestamp_widths() $("
")[0].remove = noop; // Clear stuff for testing environment add_initialize_users(); message_lists.initialize(); f(helpers); }); } // Test functions test("my_message_all_actions", ({override}) => { // Set page parameters. set_page_params_no_edit_restrictions({override}); override(realm, "realm_can_delete_any_message_group", everyone.id); override(realm, "realm_can_delete_own_message_group", everyone.id); override(realm, "realm_can_move_messages_between_topics_group", everyone.id); override(current_user, "user_id", me.user_id); // Get message with maximum permissions available // Initialize message list const list = init_message_list(); message_lists.set_current(list); // Assume message has been previously edited. // Message is sent by me, and is a stream. I should have all permissions to this message. const messages = [ { id: 1, type: "stream", sender_id: me.user_id, is_hidden: false, sent_by_me: true, locally_echoed: false, is_stream: true, stream_id: 1, unread: false, collapsed: false, hide_link_previews: false, not_spectator: true, submessages: [], edit_history: [ { prev_content: "Previous content", prev_stream: 0, prev_topic: "Previous topic", }, ], }, ]; add_message_with_view(list, messages); const response = popover_menus_data.get_actions_popover_content_context(1, { kind: "full_message", hotkeys_agree: true, }); assert.equal(response.message_id, 1); assert.equal(response.stream_id, 1); assert.equal(response.editability_menu_item, "translated: Edit message"); assert.equal(response.move_message_menu_item, "translated: Move messages"); assert.equal(response.should_display_mark_as_unread, true); assert.equal(response.view_source_menu_item, undefined); assert.equal(response.should_display_collapse, true); assert.equal(response.should_display_uncollapse, false); assert.equal(response.should_display_hide_link_previews, false); assert.equal(response.should_display_show_link_previews, false); assert.equal(response.should_display_add_reaction_option, true); assert.equal(response.conversation_time_url, "conversation_and_time_url"); assert.equal(response.should_display_delete_option, true); assert.equal(response.should_display_read_receipts_option, true); assert.equal(response.should_display_quote_message, true); assert.equal(response.show_quote_and_forward_hotkey_hints, true); // A menu opened on a message the selection does not cover quotes just // that message, which is not what > and < would do. const diverging_response = popover_menus_data.get_actions_popover_content_context(1, { kind: "full_message", hotkeys_agree: false, }); assert.equal(diverging_response.show_quote_and_forward_hotkey_hints, false); assert.equal(response.quote_message_menu_item, "translated: Quote message"); assert.equal(response.forward_message_menu_item, "translated: Forward message"); // The Quote/Forward labels spell out what a text selection will act on. const selection_response = popover_menus_data.get_actions_popover_content_context(1, { kind: "message_selection", quote_content: "selected text", }); assert.equal(selection_response.quote_message_menu_item, "translated: Quote selection"); assert.equal(selection_response.forward_message_menu_item, "translated: Forward selection"); const messages_response = popover_menus_data.get_actions_popover_content_context(1, { kind: "selected_messages", message_ids: [1, 2], }); assert.equal(messages_response.quote_message_menu_item, "translated: Quote selected messages"); assert.equal( messages_response.forward_message_menu_item, "translated: Forward selected messages", ); }); test("link_preview_actions", ({override}) => { set_page_params_no_edit_restrictions({override}); override(realm, "realm_can_delete_any_message_group", everyone.id); override(realm, "realm_can_delete_own_message_group", everyone.id); override(realm, "realm_can_move_messages_between_topics_group", everyone.id); override(current_user, "user_id", me.user_id); override(message_parser, "message_has_link_preview", () => true); const list = init_message_list(); message_lists.set_current(list); function preview_message(id, {collapsed, hide_link_previews, is_hidden = false}) { return { id, type: "stream", sender_id: me.user_id, is_hidden, sent_by_me: true, locally_echoed: false, is_stream: true, stream_id: 1, unread: false, collapsed, hide_link_previews, submessages: [], }; } // Exercise every combination of the collapsed and hide_link_previews // flags on a message that has a website preview, plus one hidden // for a muted sender. add_message_with_view(list, [ preview_message(1, {collapsed: false, hide_link_previews: false}), preview_message(2, {collapsed: false, hide_link_previews: true}), preview_message(3, {collapsed: true, hide_link_previews: false}), preview_message(4, {collapsed: true, hide_link_previews: true}), preview_message(5, {collapsed: false, hide_link_previews: false, is_hidden: true}), ]); let response = popover_menus_data.get_actions_popover_content_context(1, { kind: "full_message", hotkeys_agree: true, }); assert.equal(response.should_display_hide_link_previews, true); assert.equal(response.should_display_show_link_previews, false); response = popover_menus_data.get_actions_popover_content_context(2, { kind: "full_message", hotkeys_agree: true, }); assert.equal(response.should_display_hide_link_previews, false); assert.equal(response.should_display_show_link_previews, true); // Collapsed message: neither toggle is offered, since the preview is // already hidden along with the rest of the body. response = popover_menus_data.get_actions_popover_content_context(3, { kind: "full_message", hotkeys_agree: true, }); assert.equal(response.should_display_hide_link_previews, false); assert.equal(response.should_display_show_link_previews, false); response = popover_menus_data.get_actions_popover_content_context(4, { kind: "full_message", hotkeys_agree: true, }); assert.equal(response.should_display_hide_link_previews, false); assert.equal(response.should_display_show_link_previews, false); // A muted sender's hidden message shows a placeholder, not a preview. response = popover_menus_data.get_actions_popover_content_context(5, { kind: "full_message", hotkeys_agree: true, }); assert.equal(response.should_display_hide_link_previews, false); assert.equal(response.should_display_show_link_previews, false); // A message with no website preview offers neither toggle. override(message_parser, "message_has_link_preview", () => false); response = popover_menus_data.get_actions_popover_content_context(1, { kind: "full_message", hotkeys_agree: true, }); assert.equal(response.should_display_hide_link_previews, false); assert.equal(response.should_display_show_link_previews, false); }); test("not_my_message_view_actions", ({override}) => { set_page_params_no_edit_restrictions({override}); // Get message that is only viewable override(realm, "realm_can_delete_any_message_group", everyone.id); const list = init_message_list(); message_lists.set_current(list); // Message is sent by somebody else and is a stream with previous history. // I should only be able to view this message with no edit/move permissions. const messages = [ { id: 1, sender_id: mike.user_id, is_hidden: false, sent_by_me: false, locally_echoed: false, is_stream: true, stream_id: 1, collapsed: false, unread: false, edit_history: [ { prev_content: "Previous content", prev_stream: 0, prev_topic: "Previous topic", }, ], }, ]; add_message_with_view(list, messages); const response = popover_menus_data.get_actions_popover_content_context(1, { kind: "full_message", hotkeys_agree: true, }); assert.equal(response.view_source_menu_item, "translated: View original message"); assert.equal(response.editability_menu_item, undefined); assert.equal(response.move_message_menu_item, undefined); }); test("not_my_message_view_source_and_move", ({override}) => { set_page_params_no_edit_restrictions({override}); override(realm, "realm_can_delete_any_message_group", everyone.id); override(realm, "realm_can_move_messages_between_topics_group", everyone.id); override(current_user, "user_id", me.user_id); // Get message that is movable with viewable source const list = init_message_list(); message_lists.set_current(list); // Message tests edge case where message it sent by someone else. // Message is movable, however--I should have only view permissions with the exception of moving the message. const messages = [ { id: 1, sender_id: mike.user_id, is_hidden: false, sent_by_me: false, locally_echoed: false, is_stream: true, stream_id: 1, type: "stream", unread: false, collapsed: false, topic: "New topic", edit_history: [ { prev_content: "Previous content", prev_stream: 0, prev_topic: "Previous topic", }, ], }, ]; add_message_with_view(list, messages); const response = popover_menus_data.get_actions_popover_content_context(1, { kind: "full_message", hotkeys_agree: true, }); assert.equal(response.view_source_menu_item, "translated: View original message"); assert.equal(response.editability_menu_item, undefined); assert.equal(response.move_message_menu_item, "translated: Move messages"); }); // Helper to create a minimal message object with a given timestamp. function make_message(id, timestamp) { return {id, timestamp}; } function date_timestamp(date_string) { return new Date(date_string + "T12:00:00").getTime() / 1000; } function make_messages_from_date_counts(date_counts) { const messages = []; let id = 1; for (const {date, count} of date_counts) { for (let i = 0; i < count; i += 1) { messages.push(make_message(id, date_timestamp(date))); id += 1; } } return messages; } // Simulate a conversation with bursts separated by gaps. // Burst 1: Jan 5-7 (9 messages) // Gap: 20 days // Burst 2: Jan 27-28 (8 messages) // Gap: 15 days // Burst 3: Feb 12-14 (10 messages) // Gap: 25 days // Burst 4: Mar 11-12 (8 messages) const FOUR_MESSAGE_BURSTS = [ // Burst 1 {date: "2025-01-05", count: 3}, {date: "2025-01-06", count: 3}, {date: "2025-01-07", count: 3}, // Burst 2 {date: "2025-01-27", count: 4}, {date: "2025-01-28", count: 4}, // Burst 3 {date: "2025-02-12", count: 4}, {date: "2025-02-13", count: 3}, {date: "2025-02-14", count: 3}, // Burst 4 {date: "2025-03-11", count: 4}, {date: "2025-03-12", count: 4}, ]; run_test("get_scroll_to_date_suggestions - empty messages", () => { const result = popover_menus_data.get_scroll_to_date_suggestions([]); assert.deepEqual(result, []); }); run_test("get_scroll_to_date_suggestions - all messages today", () => { const today = new Date(); const today_ts = today.getTime() / 1000; const messages = [make_message(1, today_ts), make_message(2, today_ts + 60)]; const result = popover_menus_data.get_scroll_to_date_suggestions(messages); assert.deepEqual(result, []); }); run_test("get_scroll_to_date_suggestions - few unique dates", () => { // With 3 unique dates (all in the past), all should be suggested. const messages = [ make_message(1, date_timestamp("2025-01-10")), make_message(2, date_timestamp("2025-01-10")), make_message(3, date_timestamp("2025-02-15")), make_message(4, date_timestamp("2025-03-20")), ]; const result = popover_menus_data.get_scroll_to_date_suggestions(messages); assert.equal(result.length, 3); // Verify chronological order. assert.ok(result[0].iso_date_string < result[1].iso_date_string); assert.ok(result[1].iso_date_string < result[2].iso_date_string); }); run_test("get_scroll_to_date_suggestions - many dates with gaps", () => { const messages = make_messages_from_date_counts(FOUR_MESSAGE_BURSTS); const result = popover_menus_data.get_scroll_to_date_suggestions(messages); // We have 10 unique dates across 4 bursts; expect 3-4 suggestions. assert.ok(result.length >= 3 && result.length <= 4); // Verify chronological order. for (let i = 1; i < result.length; i += 1) { assert.ok(result[i - 1].iso_date_string < result[i].iso_date_string); } // The algorithm should prefer dates at the start of bursts (after gaps). // Jan 27 has a 20-day gap before it, Feb 12 has a 15-day gap, // Mar 11 has a 25-day gap — these should appear as suggestions. const iso_dates = result.map((s) => s.iso_date_string.slice(0, 10)); // Burst-start dates (Jan 27, Feb 12, Mar 11) should be well represented. const burst_starts = new Set(["2025-01-27", "2025-02-12", "2025-03-11"]); const burst_start_count = iso_dates.filter((d) => burst_starts.has(d)).length; assert.ok(burst_start_count >= 2, `Expected >=2 burst starts, got ${burst_start_count}`); }); run_test("get_scroll_to_date_suggestions - single date in the past", () => { const messages = [ make_message(1, date_timestamp("2025-06-15")), make_message(2, date_timestamp("2025-06-15")), ]; const result = popover_menus_data.get_scroll_to_date_suggestions(messages); assert.equal(result.length, 1); }); run_test("get_scroll_to_date_suggestions - excludes the clicked date", () => { const messages = [ make_message(1, date_timestamp("2025-01-10")), make_message(2, date_timestamp("2025-02-15")), make_message(3, date_timestamp("2025-03-20")), ]; const result = popover_menus_data.get_scroll_to_date_suggestions( messages, date_timestamp("2025-02-15"), ); assert.equal(result.length, 2); const dates = result.map((date) => date.iso_date_string.slice(0, 10)); assert.ok(!dates.includes("2025-02-15")); }); run_test("get_scroll_to_date_suggestions - clicked slot is replaced not dropped", () => { const messages = make_messages_from_date_counts(FOUR_MESSAGE_BURSTS); const clicked_date = "2025-02-12"; const baseline = popover_menus_data.get_scroll_to_date_suggestions(messages); const baseline_dates = baseline.map((s) => s.iso_date_string.slice(0, 10)); // The clicked date must be one the slicer would otherwise // pick, or this test proves nothing. assert.ok(baseline_dates.includes(clicked_date)); const filtered = popover_menus_data.get_scroll_to_date_suggestions( messages, date_timestamp(clicked_date), ); const filtered_dates = filtered.map((date) => date.iso_date_string.slice(0, 10)); assert.equal(filtered.length, baseline.length); assert.ok(!filtered_dates.includes(clicked_date)); });