diff --git a/frontend_tests/node_tests/hotkey.js b/frontend_tests/node_tests/hotkey.js index e91dc18b7d..2f6449bb3b 100644 --- a/frontend_tests/node_tests/hotkey.js +++ b/frontend_tests/node_tests/hotkey.js @@ -101,6 +101,7 @@ run_test('mappings', () => { assert.equal(map_down(219, false, true).name, 'escape'); // ctrl + [ assert.equal(map_down(75, false, true).name, 'search_with_k'); // ctrl + k assert.equal(map_down(83, false, true).name, 'star_message'); // ctrl + s + assert.equal(map_down(190, false, true).name, 'narrow_to_compose_target'); // ctrl + . // More negative tests. assert.equal(map_down(47), undefined); @@ -133,6 +134,8 @@ run_test('mappings', () => { assert.equal(map_down(75, false, true, false), undefined); // ctrl + k assert.equal(map_down(83, false, false, true).name, 'star_message'); // cmd + s assert.equal(map_down(83, false, true, false), undefined); // ctrl + s + assert.equal(map_down(190, false, false, true).name, 'narrow_to_compose_target'); // cmd + . + assert.equal(map_down(190, false, true, false), undefined); // ctrl + . // Reset userAgent global.navigator.userAgent = ''; }); @@ -309,9 +312,7 @@ run_test('basic_chars', () => { global.current_msg_list.empty = return_true; assert_mapping('n', 'narrow.narrow_to_next_topic'); - global.current_msg_list.empty = return_false; - }); run_test('motion_keys', () => { diff --git a/frontend_tests/node_tests/narrow.js b/frontend_tests/node_tests/narrow.js index 4f771224b0..55c9ccccd8 100644 --- a/frontend_tests/node_tests/narrow.js +++ b/frontend_tests/node_tests/narrow.js @@ -3,6 +3,7 @@ zrequire('hashchange'); zrequire('narrow_state'); zrequire('people'); zrequire('stream_data'); +zrequire('util'); zrequire('Filter', 'js/filter'); set_global('i18n', global.stub_i18n); @@ -177,3 +178,125 @@ run_test('show_empty_narrow_message', () => { assert.equal(hide_id,'.empty_feed_notice'); assert.equal(show_id, '#empty_search_narrow_message'); }); + +run_test('narrow_to_compose_target', () => { + set_global('compose_state', {}); + set_global('topic_data', {}); + const args = {called: false}; + const activate_backup = narrow.activate; + narrow.activate = function (operators, opts) { + args.operators = operators; + args.opts = opts; + args.called = true; + }; + + // No-op when not composing. + global.compose_state.composing = () => false; + narrow.to_compose_target(); + assert.equal(args.called, false); + global.compose_state.composing = () => true; + + // No-op when empty stream. + global.compose_state.get_message_type = () => 'stream'; + global.compose_state.stream_name = () => ''; + args.called = false; + narrow.to_compose_target(); + assert.equal(args.called, false); + + // --- Tests for stream messages --- + global.compose_state.get_message_type = () => 'stream'; + stream_data.add_sub('ROME', {name: 'ROME', stream_id: 99}); + global.compose_state.stream_name = () => 'ROME'; + global.topic_data.get_recent_names = () => ['one', 'two', 'three']; + + // Test with existing topic + global.compose_state.topic = () => 'one'; + args.called = false; + narrow.to_compose_target(); + assert.equal(args.called, true); + assert.equal(args.opts.trigger, 'narrow_to_compose_target'); + assert.deepEqual(args.operators, [ + {operator: 'stream', operand: 'ROME'}, + {operator: 'topic', operand: 'one'}, + ]); + + // Test with new topic + global.compose_state.topic = () => 'four'; + args.called = false; + narrow.to_compose_target(); + assert.equal(args.called, true); + assert.deepEqual(args.operators, [ + {operator: 'stream', operand: 'ROME'}, + ]); + + // Test with blank topic + global.compose_state.topic = () => ''; + args.called = false; + narrow.to_compose_target(); + assert.equal(args.called, true); + assert.deepEqual(args.operators, [ + {operator: 'stream', operand: 'ROME'}, + ]); + + // Test with no topic + global.compose_state.topic = () => {}; + args.called = false; + narrow.to_compose_target(); + assert.equal(args.called, true); + assert.deepEqual(args.operators, [ + {operator: 'stream', operand: 'ROME'}, + ]); + + // --- Tests for PMs --- + global.compose_state.get_message_type = () => 'private'; + people.add_in_realm(ray); + people.add_in_realm(alice); + people.add_in_realm(me); + + // Test with valid person + global.compose_state.recipient = () => 'alice@example.com'; + args.called = false; + narrow.to_compose_target(); + assert.equal(args.called, true); + assert.deepEqual(args.operators, [ + {operator: 'pm-with', operand: 'alice@example.com'}, + ]); + + // Test with valid persons + global.compose_state.recipient = () => 'alice@example.com,ray@example.com'; + args.called = false; + narrow.to_compose_target(); + assert.equal(args.called, true); + assert.deepEqual(args.operators, [ + {operator: 'pm-with', operand: 'alice@example.com,ray@example.com'}, + ]); + + // Test with some inavlid persons + global.compose_state.recipient = () => 'alice@example.com,random,ray@example.com'; + args.called = false; + narrow.to_compose_target(); + assert.equal(args.called, true); + assert.deepEqual(args.operators, [ + {operator: 'is', operand: 'private'}, + ]); + + // Test with all inavlid persons + global.compose_state.recipient = () => 'alice,random,ray'; + args.called = false; + narrow.to_compose_target(); + assert.equal(args.called, true); + assert.deepEqual(args.operators, [ + {operator: 'is', operand: 'private'}, + ]); + + // Test with no persons + global.compose_state.recipient = () => ''; + args.called = false; + narrow.to_compose_target(); + assert.equal(args.called, true); + assert.deepEqual(args.operators, [ + {operator: 'is', operand: 'private'}, + ]); + + narrow.activate = activate_backup; +}); diff --git a/static/js/compose_actions.js b/static/js/compose_actions.js index 98b859a8cd..baad5b3a4c 100644 --- a/static/js/compose_actions.js +++ b/static/js/compose_actions.js @@ -421,6 +421,11 @@ exports.on_narrow = function (opts) { return; } + if (opts.trigger === "narrow_to_compose_target") { + compose_fade.update_message_list(); + return; + } + if (narrow_state.narrowed_by_topic_reply()) { exports.on_topic_narrow(); return; diff --git a/static/js/hotkey.js b/static/js/hotkey.js index e550911575..34791ef666 100644 --- a/static/js/hotkey.js +++ b/static/js/hotkey.js @@ -55,6 +55,7 @@ var keydown_ctrl_mappings = { var keydown_cmd_or_ctrl_mappings = { 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}, // '.' }; var keydown_either_mappings = { @@ -552,6 +553,11 @@ exports.process_hotkey = function (e, hotkey) { } } + if (event_name === 'narrow_to_compose_target') { + narrow.to_compose_target(); + return true; + } + // Process hotkeys specially when in an input, select, textarea, or send button if (exports.processing_text()) { // Note that there is special handling for enter/escape too, but diff --git a/static/js/narrow.js b/static/js/narrow.js index d8ce4ffee9..1298a71875 100644 --- a/static/js/narrow.js +++ b/static/js/narrow.js @@ -599,6 +599,48 @@ exports.by_recipient = function (target_id, opts) { } }; +// Called by the narrow_to_compose_target hotkey. +exports.to_compose_target = function () { + if (!compose_state.composing()) { + return; + } + + var opts = { + trigger: 'narrow_to_compose_target', + }; + + if (compose_state.get_message_type() === 'stream') { + var stream_name = compose_state.stream_name(); + var stream_id = stream_data.get_stream_id(stream_name); + if (!stream_id) { + return; + } + // If we are composing to a new topic, we narrow to the stream but + // grey-out the message view instead of narrowing to an empty view. + var topics = topic_data.get_recent_names(stream_id); + var operators = [{operator: 'stream', operand: stream_name}]; + var topic = compose_state.topic(); + if (topics.indexOf(topic) !== -1) { + operators.push({operator: 'topic', operand: topic}); + } + exports.activate(operators, opts); + return; + } + + if (compose_state.get_message_type() === 'private') { + var recipient_string = compose_state.recipient(); + var emails = util.extract_pm_recipients(recipient_string); + var invalid = _.reject(emails, people.is_valid_email_for_compose); + // If there are no recipients or any recipient is + // invalid, narrow to all PMs. + if (emails.length === 0 || invalid.length > 0) { + exports.by('is', 'private', opts); + return; + } + exports.by('pm-with', util.normalize_recipients(recipient_string), opts); + } +}; + function handle_post_narrow_deactivate_processes() { compose_fade.update_message_list(); diff --git a/templates/zerver/app/keyboard_shortcuts.html b/templates/zerver/app/keyboard_shortcuts.html index eb50371366..50f2265bdf 100644 --- a/templates/zerver/app/keyboard_shortcuts.html +++ b/templates/zerver/app/keyboard_shortcuts.html @@ -186,6 +186,10 @@ Esc, Ctrl + [ {% trans %}Narrow to all unmuted messages{% endtrans %} + + Ctrl + . + {% trans %}Narrow to current compose box recipient{% endtrans %} + diff --git a/templates/zerver/help/keyboard-shortcuts.md b/templates/zerver/help/keyboard-shortcuts.md index 671b02a7ea..d4184a067e 100644 --- a/templates/zerver/help/keyboard-shortcuts.md +++ b/templates/zerver/help/keyboard-shortcuts.md @@ -80,6 +80,8 @@ below, and add more to your repertoire as needed. * **Narrow to all messages**: `Esc` or `Ctrl` + `[` — Shows all unmuted messages. +* **Narrow to current compose box recipient**: `Ctrl` + `.` + ## Composing messages * **Reply to message**: `r` or `Enter` — Reply to the selected