diff --git a/static/js/compose.js b/static/js/compose.js index 670b6810f2..b76fde75d5 100644 --- a/static/js/compose.js +++ b/static/js/compose.js @@ -290,12 +290,14 @@ function create_message_object() { var content = make_uploads_relative(compose.message_content()); + // Changes here must also be kept in sync with echo.try_deliver_locally var message = {client: client(), type: compose.composing(), subject: subject, stream: compose.stream_name(), private_message_recipient: compose.recipient(), - content: content}; + content: content, + queue_id: page_params.event_queue_id}; if (message.type === "private") { // TODO: this should be collapsed with the code in composebox_typeahead.js @@ -359,7 +361,7 @@ function compose_error(error_text, bad_input) { var send_options; -function send_message_ajax(request, success) { +function send_message_ajax(request, success, error) { channel.post({ url: '/json/send_message', data: request, @@ -370,8 +372,9 @@ function send_message_ajax(request, success) { reload.initiate({immediate: true, send_after_reload: true}); return; } + var response = util.xhr_error_message("Error sending message", xhr); - compose_error(response, $('#new_message_content')); + error(response); } }); } @@ -392,13 +395,13 @@ if (feature_flags.use_socket) { // For debugging. The socket will eventually move out of this file anyway. exports._socket = socket; -function send_message_socket(request, success) { +function send_message_socket(request, success, error) { socket.send(request, success, function (type, resp) { var err_msg = "Error sending message"; if (type === 'response') { err_msg += ": " + resp.msg; } - compose_error(err_msg, $('#new_message_content')); + error(err_msg); }); } @@ -459,6 +462,33 @@ function clear_compose_box() { ui.resize_bottom_whitespace(); } +exports.send_message_success = function (local_id, message_id, start_time) { + if (! feature_flags.local_echo) { + clear_compose_box(); + } + + process_send_time(message_id, start_time); + + if (feature_flags.local_echo) { + echo.reify_message_id(local_id, message_id); + } + + setTimeout(function () { + if (exports.send_times_data[message_id].received === undefined) { + blueslip.error("Restarting get_updates due to delayed receipt of sent message " + message_id); + restart_get_updates(); + } + }, 5000); +}; + +exports.transmit_message = function (request, success, error) { + if (feature_flags.use_socket) { + send_message_socket(request, success, error); + } else { + send_message_ajax(request, success, error); + } +}; + function send_message(request) { if (request === undefined) { request = create_message_object(); @@ -472,27 +502,32 @@ function send_message(request) { } var start_time = new Date(); - function success(data) { - var message_id = data.id; - process_send_time(message_id, start_time); + var local_id; + if (feature_flags.local_echo) { + local_id = echo.try_deliver_locally(request); + if (local_id !== undefined) { + // We delivered this message locally + request.local_id = local_id; + } + } - if (! feature_flags.local_echo) { - clear_compose_box(); + function success(data) { + exports.send_message_success(local_id, data.id, start_time); + } + + function error(response) { + // If we're not local echo'ing messages, or if this message was not + // locally echoed, show error in compose box + if (!feature_flags.local_echo || request.local_id === undefined) { + compose_error(response, $('#new_message_content')); + return; } - setTimeout(function () { - if (exports.send_times_data[message_id].received === undefined) { - blueslip.error("Restarting get_updates due to delayed receipt of sent message " + message_id); - restart_get_updates(); - } - }, 5000); + echo.message_send_error(local_id); } - if (feature_flags.use_socket) { - send_message_socket(request, success); - } else { - send_message_ajax(request, success); - } + exports.transmit_message(request, success, error); + if (get_updates_xhr === undefined && get_updates_timeout === undefined) { restart_get_updates({dont_block: true}); blueslip.error("Restarting get_updates because it was not running during send"); @@ -926,6 +961,14 @@ $(function () { compose.start("stream", {}); } } + + $(document).on('message_id_changed', function (event) { + if (exports.send_times_data[event.old_id] !== undefined) { + var value = exports.send_times_data[event.old_id]; + delete exports.send_times_data[event.old_id]; + exports.send_times_data[event.new_id] = _.extend({}, exports.send_times_data[event.old_id], value); + } + }); }); return exports; diff --git a/static/js/echo.js b/static/js/echo.js index 450340b685..727bd9f5cf 100644 --- a/static/js/echo.js +++ b/static/js/echo.js @@ -2,6 +2,9 @@ var echo = (function () { var exports = {}; +var waiting_for_id = {}; +var waiting_for_ack = {}; + // Regexes that match some of our common bugdown markup var bugdown_re = [ /(?::[^:\s]+:)(?!\w)/, // Emoji @@ -27,6 +30,222 @@ exports.contains_bugdown = function contains_bugdown(content) { return markedup !== undefined; }; +exports.apply_markdown = function apply_markdown(content) { + return marked(content).trim(); +}; + +function truncate_precision(float) { + return parseFloat(float.toFixed(3)); +} + +exports.try_deliver_locally = function try_deliver_locally(message_request) { + var local_id_increment = 0.01; + var next_local_id = truncate_precision(all_msg_list.last().id + local_id_increment); + + if (next_local_id % 1 === 0) { + blueslip.error("Incremented local id to next integer---100 local messages queued"); + } + + if (exports.contains_bugdown(message_request.content)) { + return undefined; + } + + // Shallow clone of message request object that is turned into something suitable + // for zulip.js:add_message + // Keep this in sync with changes to compose.create_message_object + var message = $.extend({}, message_request); + message.raw_content = message.content; + // NOTE: This will parse synchronously. We're not using the async pipeline + message.content = exports.apply_markdown(message.content); + message.content_type = 'text/html'; + // Locally delivered messages cannot be unread (since we sent them), nor + // can they alert the user + message.flags = ["read"]; + message.sender_email = page_params.email; + message.sender_full_name = page_params.fullname; + message.avatar_url = page_params.avatar_url; + message.timestamp = new XDate().getTime() / 1000; + message.local_id = next_local_id; + message.id = message.local_id; + + waiting_for_id[message.local_id] = message; + waiting_for_ack[message.local_id] = message; + + if (message.type === 'stream') { + message.display_recipient = message.stream; + } else { + // Build a display recipient with the full names of each recipient + var emails = message_request.private_message_recipient.split(','); + message.display_recipient = _.map(emails, function (email) { + email = email.trim(); + var person = people_dict.get(email); + if (person !== undefined) { + return person; + } + return {email: email, full_name: email}; + }); + } + + insert_new_messages([message]); + + blueslip.debug("Generated local id " + message.local_id); + return message.local_id; +}; + +exports.reify_message_id = function reify_message_id(local_id, server_id) { + var message = waiting_for_id[local_id]; + delete waiting_for_id[local_id]; + + // reify_message_id is called both on receiving a self-sent message + // from the server, and on receiving the response to the send request + // Reification is only needed the first time the server id is found + if (message === undefined) { + return; + } + + blueslip.debug("Reifying ID: " + local_id + " TO " + server_id); + message.id = server_id; + delete message.local_id; + + // We have the real message ID for this message + $(document).trigger($.Event('message_id_changed', {old_id: local_id, new_id: server_id})); +}; + +exports.process_from_server = function process_from_server(messages) { + var updated = false; + var locally_processed_ids = []; + messages = _.filter(messages, function (message) { + // In case we get the sent message before we get the send ACK, reify here + exports.reify_message_id(message.local_id, message.id); + + var client_message = waiting_for_ack[message.local_id]; + if (client_message !== undefined) { + if (client_message.content !== message.content) { + client_message.content = message.content; + updated = true; + } + // If a PM was sent to an out-of-realm address, + // we didn't have the full person object originally, + // so we might have to update the recipient bar and + // internal data structures + if (client_message.type === 'private') { + var reply_to = get_private_message_recipient(message, 'full_name', 'email'); + if (client_message.display_reply_to !== reply_to) { + client_message.display_reply_to = reply_to; + _.each(message.display_recipient, function (person) { + if (people_dict.get(person.email).full_name !== person.full_name) { + reify_person(person); + } + }); + updated = true; + } + } + locally_processed_ids.push(client_message.id); + delete waiting_for_ack[client_message.id]; + return false; + } + return true; + }); + + if (updated) { + // TODO just rerender the message, not the whole list + home_msg_list.rerender(); + if (current_msg_list === narrowed_msg_list) { + narrowed_msg_list.rerender(); + } + } else { + _.each(locally_processed_ids, function (id) { + ui.show_local_message_arrived(id); + }); + } + return messages; +}; + +exports.message_send_error = function message_send_error(local_id) { + // Error sending message, show inline + all_msg_list.get(local_id).failed_request = true; + ui.show_message_failed(local_id); +}; + +function resend_message(message) { + message.content = message.raw_content; + compose.transmit_message(message, function success(data) { + var message_id = data.id; + var local_id = data.local_id; + + exports.reify_message_id(local_id, message_id); + + // Resend succeeded, so mark as no longer failed + all_msg_list.get(message_id).failed_request = false; + ui.show_failed_message_success(message_id); + }, function error() { + blueslip.log("Manual resend of message failed"); + }); +} + +function abort_message(message) { + // Remove in all lists in which it exists + _.each([all_msg_list, home_msg_list, current_msg_list], function (msg_list) { + msg_list.remove_and_rerender([message]); + }); +} + +$(function () { + function disable_markdown_regex(rules, name) { + rules[name] = {exec: function (_) { + return false; + } + }; + } + + // Configure the marked markdown parser for our usage + var r = new marked.Renderer(); + + // Disable ordered lists + // We used GFM + tables, so replace the list start regex for that ruleset + // We remove the |[\d+]\. that matches the numbering in a numbered list + marked.Lexer.rules.tables.list = /^( *)((?:\*)) [\s\S]+?(?:\n+(?=(?: *[\-*_]){3,} *(?:\n+|$))|\n{2,}(?! )(?!\1(?:\*) )\n*|\s*$)/; + // marked.Lexer.rules.tables + // Disable headings + disable_markdown_regex(marked.Lexer.rules.tables, 'heading'); + disable_markdown_regex(marked.Lexer.rules.tables, 'lheading'); + + // Disable __strong__, all + marked.InlineLexer.rules.breaks.strong = /^\*\*([\s\S]+?)\*\*(?!\*)/; + disable_markdown_regex(marked.InlineLexer.rules.breaks, 'em'); + disable_markdown_regex(marked.InlineLexer.rules.breaks, 'del'); + + marked.setOptions({ + gfm: true, + tables: true, + breaks: true, + pedantic: false, + sanitize: true, + smartLists: true, + smartypants: false, + renderer: r + }); + + function on_failed_action(action, callback) { + $("#main_div").on("click", "." + action + "-failed-message", function (e) { + e.stopPropagation(); + popovers.hide_all(); + var message_id = rows.id($(this).closest(".message_row")); + // Message should be waiting for ack and only have a local id, + // otherwise send would not have failed + var message = waiting_for_ack[message_id]; + if (message === undefined) { + blueslip.warning("Got resend or retry on failure request but did not find message in ack list " + message_id); + return; + } + callback(message); + }); + } + + on_failed_action('remove', abort_message); + on_failed_action('refresh', resend_message); +}); + return exports; }()); diff --git a/static/js/feature_flags.js b/static/js/feature_flags.js index 539ea1652a..0de74c32e9 100644 --- a/static/js/feature_flags.js +++ b/static/js/feature_flags.js @@ -41,6 +41,8 @@ exports.left_side_userlist = page_params.staging || _.contains(['customer7.inval exports.show_autoscroll_forever_option = page_params.show_autoscroll_forever_option; // Still very beta: +exports.local_echo = page_params.staging; + exports.full_width = false; //page_params.staging; exports.local_echo = page_params.staging; diff --git a/static/js/message_list.js b/static/js/message_list.js index 9668d4cb82..f7181be11a 100644 --- a/static/js/message_list.js +++ b/static/js/message_list.js @@ -24,6 +24,7 @@ function MessageList(table_name, filter, opts) { this.num_appends = 0; this.min_id_exempted_from_summaries = -1; + return this; } @@ -514,6 +515,64 @@ MessageList.prototype = { } }); this.view.rerender_the_whole_thing(); + }, + + change_message_id: function MessageList_change_message_id(old_id, new_id) { + // Update our local cache that uses the old id to the new id + function message_sort_func(a, b) {return a.id - b.id;} + + function is_local_only(message) { + return message.id % 1 !== 0; + } + + function next_nonlocal_message(item_list, start_index, op) { + var cur_idx = start_index; + do { + cur_idx = op(cur_idx); + } while(item_list[cur_idx] !== undefined && is_local_only(item_list[cur_idx])); + return item_list[cur_idx]; + } + + if (this._hash.hasOwnProperty(old_id)) { + var value = this._hash[old_id]; + delete this._hash[old_id]; + this._hash[new_id] = value; + } + + if (this._selected_id === old_id) { + this._selected_id = new_id; + } + + if (this.min_id_exempted_from_summaries === old_id) { + this.min_id_exempted_from_summaries = new_id; + } + + // If this message is now out of order, re-order and re-render + var self = this; + setTimeout(function () { + var current_message = self._hash[new_id]; + var index = self._items.indexOf(current_message); + + if (index === -1) { + if ( !self.muting_enabled && current_msg_list === self) { + blueslip.error("Trying to re-order message but can't find message with new_id in _items!"); + } + return; + } + + var next = next_nonlocal_message(self._items, index, function (idx) { return idx + 1; }); + var prev = next_nonlocal_message(self._items, index, function (idx) { return idx - 1; }); + + if ((next !== undefined && current_message.id > next.id) || + (prev !== undefined && current_message.id < prev.id)) { + blueslip.debug("Changed message ID from server caused out-of-order list, reordering"); + self._items.sort(message_sort_func); + if (self.muting_enabled) { + self._all_items.sort(message_sort_func); + } + self.view.rerender_the_whole_thing(); + } + }, 0); } }; diff --git a/static/js/message_list_view.js b/static/js/message_list_view.js index 5af6c581a9..0a514182a6 100644 --- a/static/js/message_list_view.js +++ b/static/js/message_list_view.js @@ -695,8 +695,32 @@ MessageListView.prototype = { get_message: function MessageListView_get_message(id) { return this.list.get(id); - } + }, + change_message_id: function MessageListView_change_message_id(old_id, new_id) { + if (this._rows[old_id] !== undefined) { + var row = this._rows[old_id]; + delete this._rows[old_id]; + + var prev_recipient_row = $(row).prev('.recipient_row'); + if (prev_recipient_row.length > 0 && + parseFloat(prev_recipient_row.attr('zid')) === old_id) { + prev_recipient_row.attr('zid', new_id); + + var messages = prev_recipient_row.attr('data-messages').split(); + var fixed_messages = _.map(messages, function (msgid) { + if (parseFloat(msgid) === old_id) { + return String(new_id); + } + }); + prev_recipient_row.attr('data-messages', fixed_messages.join(" ")); + + } + row.setAttribute('zid', new_id); + row.setAttribute('id', this.table_name + new_id); + this._rows[new_id] = row; + } + } }; }()); diff --git a/static/js/narrow.js b/static/js/narrow.js index 033c7cba8d..86f0bf03ba 100644 --- a/static/js/narrow.js +++ b/static/js/narrow.js @@ -257,7 +257,7 @@ exports.activate = function (operators, opts) { var defer_selecting_closest = narrowed_msg_list.empty(); load_old_messages({ - anchor: then_select_id, + anchor: then_select_id.toFixed(), num_before: 50, num_after: 50, msg_list: narrowed_msg_list, diff --git a/static/js/ui.js b/static/js/ui.js index a39d425dcd..e1875e0c41 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -700,26 +700,33 @@ function sync_message_star(message, starred) { sync_message_flag([message], "starred", starred); } +function update_message_in_all_views(message_id, callback) { + _.each([all_msg_list, home_msg_list, narrowed_msg_list], function (list) { + if (list === undefined) { + return; + } + var row = list.get_row(message_id); + if (row === undefined) { + // The row may not exist, e.g. if you do an action on a message in + // a narrowed view + return; + } + callback(row); + }); +} + exports.update_starred = function (message_id, starred) { + // Update the message object pointed to by the various message + // lists. + var message = current_msg_list.get(message_id); + + mark_message_as_read(message); + + message.starred = message.starred !== true; + // Avoid a full re-render, but update the star in each message // table in which it is visible. - _.each([all_msg_list, home_msg_list, narrowed_msg_list], function (msg_list) { - if (msg_list === undefined) { - return; - } - - var message = msg_list.get(message_id); - if (message === undefined) { - return; - } - message.starred = starred; - - var row = msg_list.get_row(message.id); - if (row === undefined) { - // The row may not exist, e.g. if you star a message in the all - // messages table from a stream that isn't in your home view. - return; - } + update_message_in_all_views(message_id, function update_row(row) { var elt = row.find(".message_star"); if (starred) { elt.addClass("icon-vector-star").removeClass("icon-vector-star-empty").removeClass("empty-star"); @@ -740,6 +747,35 @@ function toggle_star(message_id) { sync_message_star(message, message.starred); } +var local_messages_to_show = []; +var show_message_timestamps = _.throttle(function () { + _.each(local_messages_to_show, function (message_id) { + update_message_in_all_views(message_id, function update_row(row) { + row.find('.message_time').toggleClass('notvisible', false); + }); + }); + local_messages_to_show = []; +}, 100); + +exports.show_local_message_arrived = function (message_id) { + local_messages_to_show.push(message_id); + show_message_timestamps(); +}; + +exports.show_message_failed = function (message_id) { + // Failed to send message, so display inline retry/cancel + update_message_in_all_views(message_id, function update_row(row) { + row.find('.message_failed').toggleClass('notvisible', false); + }); +}; + +exports.show_failed_message_success = function (message_id) { + // Previously failed message succeeded + update_message_in_all_views(message_id, function update_row(row) { + row.find('.message_failed').toggleClass('notvisible', true); + }); +}; + exports.small_avatar_url = function (message) { // Try to call this function in all places where we need 25px // avatar images, so that the browser can help diff --git a/static/js/zulip.js b/static/js/zulip.js index 9b15c86b9b..4c211decf2 100644 --- a/static/js/zulip.js +++ b/static/js/zulip.js @@ -320,18 +320,18 @@ function message_range(msg_list, start, end) { function batched_flag_updater(flag, op) { var queue = []; - - function on_success(data, status, jqXHR) { - queue = _.filter(queue, function (message) { - return data.messages.indexOf(message) === -1; - }); - } + var on_success; function server_request() { + // Wait for server IDs before sending flags + var real_msgs = _.filter(queue, function (msg) { + return msg.local_id === undefined; + }); + channel.post({ url: '/json/update_message_flags', idempotent: true, - data: {messages: JSON.stringify(queue), + data: {messages: JSON.stringify(real_msgs), op: op, flag: flag}, success: on_success @@ -340,6 +340,20 @@ function batched_flag_updater(flag, op) { var start = _.debounce(server_request, 1000); + on_success = function on_success(data, status, jqXHR) { + if (data === undefined || data.messages === undefined) { + return; + } + + queue = _.filter(queue, function (message) { + return data.messages.indexOf(message) === -1; + }); + + if (queue.length > 0) { + start(); + } + }; + function add(message) { if (message.flags === undefined) { message.flags = []; @@ -900,6 +914,9 @@ function get_updates_success(data) { case 'message': var msg = event.message; msg.flags = event.flags; + if (event.local_message_id !== undefined) { + msg.local_id = event.local_message_id; + } messages.push(msg); break; case 'pointer': @@ -995,6 +1012,7 @@ function get_updates_success(data) { }); if (messages.length !== 0) { + messages = echo.process_from_server(messages); insert_new_messages(messages); } @@ -1356,8 +1374,11 @@ function main() { if (event.id === -1) { return; } + // Additionally, don't advance the pointer server-side + // if the selected message is local-only if (event.msg_list === home_msg_list && page_params.narrow_stream === undefined) { - if (event.id > furthest_read) { + if (event.id > furthest_read && + home_msg_list.get(event.id).local_id === undefined) { furthest_read = event.id; } } @@ -1429,6 +1450,36 @@ function main() { } else { get_updates(); } + + $(document).on('message_id_changed', function (event) { + var old_id = event.old_id, new_id = event.new_id; + if (furthest_read === old_id) { + furthest_read = new_id; + } + if (get_updates_params.pointer === old_id) { + get_updates_params.pointer = new_id; + } + if (msg_metadata_cache[old_id]) { + msg_metadata_cache[new_id] = msg_metadata_cache[old_id]; + delete msg_metadata_cache[old_id]; + } + + // This handler cannot be in the MessageList constructor, which is the logical place + // If it's there, the event handler creates a closure with a reference to the message + // list itself. When narrowing, the old narrow message list is discarded and a new one + // created, but due to the closure, the old list is not garbage collected. This also leads + // to the old list receiving the change id events, and throwing errors as it does not + // have the messages that you would expect in its internal data structures. + _.each([all_msg_list, home_msg_list, narrowed_msg_list], function (msg_list) { + if (msg_list !== undefined) { + msg_list.change_message_id(old_id, new_id); + + if (msg_list.view !== undefined) { + msg_list.view.change_message_id(old_id, new_id); + } + } + }); + }); } function install_main_scroll_handler() { diff --git a/static/styles/zulip.css b/static/styles/zulip.css index 1d19b91ff1..71dbbba5e1 100644 --- a/static/styles/zulip.css +++ b/static/styles/zulip.css @@ -1066,6 +1066,24 @@ just a temporary hack. color: #0088CC; } +.message_failed, +.message_local { + display: inline-block; + cursor: pointer; + font-size: 13px; +} + +.message_failed { + font-weight: bold; + color: red; + padding: 0px 1px 0px 1px; +} + +.message_failed i { + margin-left: 2px; + margin-right: 2px; +} + a.message_label_clickable:hover { cursor: pointer; color: #08C; diff --git a/static/templates/message.handlebars b/static/templates/message.handlebars index 33552a036e..d084706905 100644 --- a/static/templates/message.handlebars +++ b/static/templates/message.handlebars @@ -167,7 +167,7 @@ {{/include_recipient}}
+ class="message_row{{^is_stream}} private-message{{/is_stream}}{{#include_sender}} include-sender{{/include_sender}}{{#contains_mention}} mention{{/contains_mention}}{{#include_footer}} last_message{{/include_footer}}{{#unread}} unread{{/unread}} {{#if local_id}}local{{/if}} selectable_row">
@@ -194,10 +194,13 @@
- {{timestr}} + {{timestr}}
+
+ Not delivered +
{{#unless status_message}}{{#if ../../../../use_match_properties}}{{{match_content}}}{{else}}{{{content}}}{{/if}}{{/unless}}
diff --git a/tools/jslint/check-all.js b/tools/jslint/check-all.js index 11c3da4798..07f84d7f6f 100644 --- a/tools/jslint/check-all.js +++ b/tools/jslint/check-all.js @@ -4,7 +4,7 @@ var globals = // Third-party libraries ' $ _ jQuery Spinner Handlebars XDate zxcvbn Intl mixpanel Notification' - + ' LazyLoad Dropbox SockJS' + + ' LazyLoad Dropbox SockJS marked' // Node-based unit tests + ' module' @@ -41,6 +41,9 @@ var globals = // alert_words.js + ' alert_words' + // echo.js + + ' echo' + // zulip.js + ' all_msg_list home_msg_list narrowed_msg_list current_msg_list get_updates_params' + ' add_messages'