diff --git a/frontend_tests/node_tests/message_fetch.js b/frontend_tests/node_tests/message_fetch.js index ea2e44a06f..fa490682bc 100644 --- a/frontend_tests/node_tests/message_fetch.js +++ b/frontend_tests/node_tests/message_fetch.js @@ -11,9 +11,11 @@ zrequire('FetchStatus', 'js/fetch_status'); zrequire('Filter', 'js/filter'); zrequire('MessageListData', 'js/message_list_data'); zrequire('message_list'); -zrequire('recent_topics'); zrequire('people'); +set_global('recent_topics', { + process_messages: noop, +}); set_global('page_params', { pointer: 444, }); diff --git a/frontend_tests/node_tests/recent_topics.js b/frontend_tests/node_tests/recent_topics.js index 2669680926..805a425b95 100644 --- a/frontend_tests/node_tests/recent_topics.js +++ b/frontend_tests/node_tests/recent_topics.js @@ -1,4 +1,3 @@ -let rt = zrequire('recent_topics'); zrequire('message_util'); set_global('$', global.make_zjquery()); @@ -15,6 +14,25 @@ set_global('people', { return id === 1; }, }); +set_global('XDate', zrequire('XDate', 'xdate')); +set_global('timerender', { + last_seen_status_from_date: () => { + return "Just now"; + }, +}); +set_global('unread', { + unread_topic_counter: { + get: () => { return 1; }, + }, +}); +set_global('hash_util', { + by_stream_uri: () => { + return "https://www.example.com"; + }, + by_stream_topic_uri: () => { + return "https://www.example.com"; + }, +}); // Custom Data @@ -57,11 +75,17 @@ set_global('message_list', { }, }, }); +set_global('message_store', { + get: (msg_id) => { + return messages[msg_id - 1]; + }, +}); let id = 0; messages[0] = { stream_id: stream1, + stream: 'stream1', id: id += 1, topic: topic1, sender_id: sender1, @@ -70,6 +94,7 @@ messages[0] = { messages[1] = { stream_id: stream1, + stream: 'stream1', id: id += 1, topic: topic2, sender_id: sender1, @@ -78,6 +103,7 @@ messages[1] = { messages[2] = { stream_id: stream1, + stream: 'stream1', id: id += 1, topic: topic2, sender_id: sender2, @@ -87,6 +113,7 @@ messages[2] = { messages[3] = { stream_id: stream1, + stream: 'stream1', id: id += 1, topic: topic3, sender_id: sender2, @@ -95,6 +122,7 @@ messages[3] = { messages[4] = { stream_id: stream1, + stream: 'stream1', id: id += 1, topic: topic4, sender_id: sender2, @@ -104,6 +132,7 @@ messages[4] = { messages[5] = { stream_id: stream1, + stream: 'stream1', id: id += 1, topic: topic5, sender_id: sender1, @@ -112,6 +141,7 @@ messages[5] = { messages[6] = { stream_id: stream1, + stream: 'stream1', id: id += 1, topic: topic5, sender_id: sender2, @@ -120,6 +150,7 @@ messages[6] = { messages[7] = { stream_id: stream1, + stream: 'stream1', id: id += 1, topic: topic6, sender_id: sender1, @@ -128,6 +159,7 @@ messages[7] = { messages[8] = { stream_id: stream1, + stream: 'stream1', id: id += 1, topic: topic6, sender_id: sender2, @@ -136,6 +168,7 @@ messages[8] = { messages[9] = { stream_id: stream1, + stream: 'stream1', id: id += 1, topic: topic7, sender_id: sender1, @@ -143,22 +176,120 @@ messages[9] = { }; function verify_topic_data(all_topics, stream, topic, last_msg_id, - participated, starred_count, is_muted) { - // default is_muted to false since most of the test cases will - // be not muted - is_muted = is_muted || false; + participated, starred_count) { const topic_data = all_topics.get(stream + ':' + topic); assert.equal(topic_data.last_msg_id, last_msg_id); assert.equal(topic_data.participated, participated); assert.equal(topic_data.starred.size, starred_count); - assert.equal(topic_data.muted, is_muted); } -run_test('basic assertions', () => { +run_test("test_recent_topics_launch", () => { + // Note: unread count and urls are fake, + // since they are generated in external libraries + // and are not to be tested here. + const expected = { + recent_topics: [ + { + stream_id: 1, + stream: 'stream1', + topic: 'topic-7', + unread_count: 1, + last_msg_time: 'Just now', + stream_url: 'https://www.example.com', + topic_url: 'https://www.example.com', + hidden: true, + }, + { + stream_id: 1, + stream: 'stream1', + topic: 'topic-6', + unread_count: 1, + last_msg_time: 'Just now', + stream_url: 'https://www.example.com', + topic_url: 'https://www.example.com', + hidden: false, + }, + { + stream_id: 1, + stream: 'stream1', + topic: 'topic-5', + unread_count: 1, + last_msg_time: 'Just now', + stream_url: 'https://www.example.com', + topic_url: 'https://www.example.com', + hidden: false, + }, + { + stream_id: 1, + stream: 'stream1', + topic: 'topic-4', + unread_count: 1, + last_msg_time: 'Just now', + stream_url: 'https://www.example.com', + topic_url: 'https://www.example.com', + hidden: false, + }, + { + stream_id: 1, + stream: 'stream1', + topic: 'topic-3', + unread_count: 1, + last_msg_time: 'Just now', + stream_url: 'https://www.example.com', + topic_url: 'https://www.example.com', + hidden: false, + }, + { + stream_id: 1, + stream: 'stream1', + topic: 'topic-2', + unread_count: 1, + last_msg_time: 'Just now', + stream_url: 'https://www.example.com', + topic_url: 'https://www.example.com', + hidden: false, + }, + { + stream_id: 1, + stream: 'stream1', + topic: 'topic-1', + unread_count: 1, + last_msg_time: 'Just now', + stream_url: 'https://www.example.com', + topic_url: 'https://www.example.com', + hidden: false, + }, + ], + }; + global.stub_templates(function (template_name, data) { + assert.equal(template_name, 'recent_topics_table'); + assert.deepEqual(data, expected); + return ''; + }); + + const rt = zrequire('recent_topics'); + rt.process_messages(messages); + + rt.launch(); + overlays.close_callback(); + + // incorrect topic_key + assert.equal(rt.inplace_rerender('stream_unknown:topic_unknown'), false); +}); + +// template rendering is tested in test_recent_topics_launch. +global.stub_templates(function () { + return ''; +}); + +run_test('basic assertions', () => { + const rt = zrequire('recent_topics'); rt.process_messages(messages); let all_topics = rt.get(); + // update a message + rt.process_messages([messages[9]]); // Check for expected lengths. // total 7 topics, 1 muted assert.equal(all_topics.size, 7); @@ -218,6 +349,7 @@ run_test('basic assertions', () => { verify_topic_data(all_topics, stream1, topic3, id, true, 0); // Send new message to topic7 (muted) + // The topic will be hidden when displayed rt.process_message({ stream_id: stream1, id: id += 1, @@ -229,24 +361,20 @@ run_test('basic assertions', () => { all_topics = rt.get(); assert.equal(Array.from(all_topics.keys()).toString(), '1:topic-7,1:topic-3,1:topic-1,1:topic-6,1:topic-5,1:topic-4,1:topic-2'); - verify_topic_data(all_topics, stream1, topic7, id, true, 0, true); // unmute topic7 - rt.update_topic_is_muted(stream1, topic7, false); - all_topics = rt.get(); - verify_topic_data(all_topics, stream1, topic7, id, true, 0, false); + assert.equal(rt.update_topic_is_muted(stream1, topic7, false), true); // mute topic7 - rt.update_topic_is_muted(stream1, topic7, true); - all_topics = rt.get(); - verify_topic_data(all_topics, stream1, topic7, id, true, 0, true); + assert.equal(rt.update_topic_is_muted(stream1, topic7, true), true); // a topic gets muted which we are not tracking assert.equal(rt.update_topic_is_muted(stream1, "topic-10", true), false); }); run_test('test_topic_edit', () => { - rt = zrequire('recent_topics'); + // NOTE: This test should always run in the end as it modified the messages data. + const rt = zrequire('recent_topics'); rt.process_messages(messages); let all_topics = rt.get(); @@ -266,19 +394,6 @@ run_test('test_topic_edit', () => { verify_topic_data(all_topics, stream1, topic8, messages[8].id, true, 0); assert.equal(all_topics.get(stream1 + ":" + topic6), undefined); - ////////////////// test change topic to muted topic ////////////////// - verify_topic_data(all_topics, stream1, topic8, messages[8].id, true, 0); - verify_topic_data(all_topics, stream1, topic7, messages[9].id, true, 0, true); - - // change topic of topic8 to topic7 - messages[7].topic = topic7; - messages[8].topic = topic7; - rt.process_topic_edit(stream1, topic8, topic7); - all_topics = rt.get(); - - assert.equal(all_topics.get(stream1 + ":" + topic8), undefined); - verify_topic_data(all_topics, stream1, topic7, messages[9].id, true, 0, true); - ////////////////// test stream change ////////////////// verify_topic_data(all_topics, stream1, topic1, messages[0].id, true, 0); assert.equal(all_topics.get(stream2 + ":" + topic1), undefined); @@ -302,13 +417,3 @@ run_test('test_topic_edit', () => { assert.equal(all_topics.get(stream2 + ":" + topic1), undefined); verify_topic_data(all_topics, stream3, topic9, messages[0].id, true, 0); }); - -run_test("test_recent_topics_launch", () => { - - global.stub_templates(function (template_name) { - assert.equal(template_name, 'recent_topics_table'); - return ''; - }); - rt.launch(); - overlays.close_callback(); -}); diff --git a/frontend_tests/zjsunit/zjquery.js b/frontend_tests/zjsunit/zjquery.js index 1467f46126..7f401876e5 100644 --- a/frontend_tests/zjsunit/zjquery.js +++ b/frontend_tests/zjsunit/zjquery.js @@ -528,11 +528,21 @@ exports.make_zjquery = function (opts) { }; }; + fn.after = function (s) { + return s; + }; + fn.before = function (s) { + return s; + }; + zjquery.fn = fn; zjquery.clear_all_elements = function () { elems.clear(); }; + zjquery.escapeSelector = function (s) { + return s; + }; return zjquery; }; diff --git a/static/js/recent_topics.js b/static/js/recent_topics.js index 7c9ab6a97a..714dc93abd 100644 --- a/static/js/recent_topics.js +++ b/static/js/recent_topics.js @@ -1,13 +1,24 @@ const render_recent_topics_body = require('../templates/recent_topics_table.hbs'); +const render_recent_topic_row = require('../templates/recent_topic_row.hbs'); const topics = new Map(); // Key is stream-id:topic. exports.process_messages = function (messages) { + // Since a complete re-render is expensive, we + // only do it if there are more than 5 messages + // to process. + let do_inplace_rerender = true; + if (messages.length > 5) { + do_inplace_rerender = false; + } for (const msg of messages) { - exports.process_message(msg); + exports.process_message(msg, do_inplace_rerender); + } + if (!do_inplace_rerender) { + exports.complete_rerender(); } }; -exports.process_message = function (msg) { +exports.process_message = function (msg, do_inplace_rerender) { if (msg.type !== 'stream') { return false; } @@ -18,7 +29,6 @@ exports.process_message = function (msg) { last_msg_id: -1, starred: new Set(), participated: false, - muted: false, }); } // Update topic data @@ -31,17 +41,11 @@ exports.process_message = function (msg) { topic_data.starred.add(msg.id); } topic_data.participated = is_ours || topic_data.participated; - topic_data.muted = topic_data.muted || muting.is_topic_muted(msg.stream_id, msg.topic); - return true; -}; -exports.update_topic_is_muted = function (stream_id, topic, is_muted) { - const key = stream_id + ":" + topic; - if (!topics.has(key)) { - return false; + if (do_inplace_rerender) { + exports.inplace_rerender(key); } - const topic_data = topics.get(stream_id + ":" + topic); - topic_data.muted = is_muted; + return true; }; function get_sorted_topics() { @@ -68,10 +72,101 @@ exports.process_topic_edit = function (old_stream_id, old_topic, new_topic, new_ exports.process_messages(new_topic_msgs); }; -exports.launch = function () { - const rendered_body = render_recent_topics_body(); - $('#recent_topics_table').html(rendered_body); +function format_topic(topic_data) { + const last_msg = message_store.get(topic_data.last_msg_id); + const stream = last_msg.stream; + const stream_id = last_msg.stream_id; + const topic = last_msg.topic; + const time = new XDate(last_msg.timestamp * 1000); + const last_msg_time = timerender.last_seen_status_from_date(time); + const unread_count = unread.unread_topic_counter.get(stream_id, topic); + const hidden = muting.is_topic_muted(stream_id, topic); + return { + stream_id: stream_id, + stream: stream, + topic: topic, + unread_count: unread_count, + last_msg_time: last_msg_time, + stream_url: hash_util.by_stream_uri(stream_id), + topic_url: hash_util.by_stream_topic_uri(stream_id, topic), + hidden: hidden, + }; +} +function format_all_topics() { + const topics_array = []; + for (const [, value] of exports.get()) { + topics_array.push(format_topic(value)); + } + return topics_array; +} + +function get_topic_row(topic_key) { + // topic_key = stream_id + ":" + topic + return $("#" + $.escapeSelector("recent_topic:" + topic_key)); +} + +exports.inplace_rerender = function (topic_key) { + // We remove topic from the UI and reinsert it. + // This makes sure we maintain the correct order + // of topics. + const topic_data = topics.get(topic_key); + if (topic_data === undefined) { + return false; + } + const formatted_values = format_topic(topic_data); + const topic_row = get_topic_row(topic_key); + topic_row.remove(); + + const rendered_row = render_recent_topic_row(formatted_values); + + const sorted_topic_keys = Array.from(get_sorted_topics().keys()); + const topic_index = sorted_topic_keys.findIndex( + function (key) { + if (key === topic_key) { + return true; + } + return false; + } + ); + + if (topic_index === 0) { + // Note: In this function length of sorted_topic_keys is always >= 2, + // since it is called after a complete_rerender has taken place. + // A complete_rerender only takes place after there is a topic to + // display. So, this can at min be the second topic we are dealing with. + get_topic_row(sorted_topic_keys[1]).before(rendered_row); + } + get_topic_row(sorted_topic_keys[topic_index - 1]).after(rendered_row); +}; + +exports.update_topic_is_muted = function (stream_id, topic, is_muted) { + const key = stream_id + ":" + topic; + if (!topics.has(key)) { + // we receive mute request for a topic we are + // not tracking currently + return false; + } + + if (is_muted) { + get_topic_row(key).hide(); + } else { + get_topic_row(key).show(); + } + return true; +}; + +exports.complete_rerender = function () { + // NOTE: This function is grows expensive with + // number of topics. Only call when necessary. + // This functions takes around 1ms per topic to process. + const rendered_body = render_recent_topics_body({ + recent_topics: format_all_topics(), + }); + $('#recent_topics_table').html(rendered_body); +}; + +exports.launch = function () { overlays.open_overlay({ name: 'recent_topics', overlay: $('#recent_topics_overlay'), diff --git a/static/styles/night_mode.scss b/static/styles/night_mode.scss index 420128bf94..428dc65049 100644 --- a/static/styles/night_mode.scss +++ b/static/styles/night_mode.scss @@ -421,6 +421,11 @@ on a dark background, and don't change the dark labels dark either. */ background-color: hsla(0, 0%, 0%, 0.2); } + .table-hover tbody tr:hover td, + .table-hover tbody tr:hover th { + background-color: hsla(0, 0%, 0%, 0.5); + } + .table-striped tbody tr:nth-child(odd) td { background-color: hsl(212, 28%, 18%); } diff --git a/static/styles/recent_topics.scss b/static/styles/recent_topics.scss index 2fce56778c..b0fde1b629 100644 --- a/static/styles/recent_topics.scss +++ b/static/styles/recent_topics.scss @@ -56,5 +56,18 @@ margin: 0px; padding: 15px; overflow: auto; + + thead { + background-color: hsl(0, 0%, 27%); + color: hsl(0, 0%, 100%); + } + + .recent_topic_unread_count { + text-align: center; + // width is kept less than width of + // header text "Unread" so, that final width + // is set to header's width. + width: 1px; + } } } diff --git a/static/templates/recent_topic_row.hbs b/static/templates/recent_topic_row.hbs new file mode 100644 index 0000000000..28c6b4edf9 --- /dev/null +++ b/static/templates/recent_topic_row.hbs @@ -0,0 +1,23 @@ + + + {{#if unread_count}} + {{unread_count}} + {{else}} + + {{/if}} + + + {{stream}} + + + {{topic}} + + + + + + + + {{ last_msg_time }} + + diff --git a/static/templates/recent_topics_table.hbs b/static/templates/recent_topics_table.hbs index d77e456015..b323711241 100644 --- a/static/templates/recent_topics_table.hbs +++ b/static/templates/recent_topics_table.hbs @@ -1,2 +1,15 @@ - +
+ + + + + + + + + + + {{#each recent_topics}} + {{> recent_topic_row}} + {{/each}}
{{t 'Unread' }}{{t 'Stream' }}{{t 'Topic' }}{{t 'Actions' }}{{t 'Participants' }}{{t 'Last message' }}