Files
zulip/static/js/echo.js
Tim Abbott 0d90bb2569 narrow: Fix messages being cached without flags set.
f0c680e9c0 introduced a call to
message_helper.process_new_message without first calling
message_store.set_message_flags on the message.

This resulted in it being possible as a race, when loading the Zulip
app to a stream/topic/near narrow, for a message to have the
`historical` flag be undefined due to not being initialized.

That invalid state, in turn, resulted in the message_list_view code
path for rendering the message feed incorrectly displaying additional
recipient bars around the message.

We could fix this by just calling message_store.set_message_booleans
in this code path. However, this bug exposes the fact that it's very
fragile to expect every code path to call that function before
message_helper.process_new_message.

So we instead fix this by moving message_store.set_message_booleans
inside message_helper.process_new_message.

One call point of concern in this change is maybe_add_narrow_messages,
which could theoretically reintroduce the double set_message_flags
bugs detailed in 9729b1a4ad. However, I
believe that to not be possible, because that call should never
experience a cache miss.

The other existing code paths were already calling
set_message_booleans immediately before
message_helper.process_new_message. They are still changing here, in
that we now do a cache lookup before attempting to call
set_message_booleans. Because the message booleans do not affect the
cache lookup and the local message object is discarded in case of a
cache hit, this should have no functional impact.

Because I found the existing comment at that call site confusing and
almost proposed removing it as pointless, extend the block comment to
explicitly mention that the purpose is refreshing our object.

Fixes #21503.
2022-03-24 08:10:14 -07:00

489 lines
18 KiB
JavaScript

import $ from "jquery";
import * as alert_words from "./alert_words";
import {all_messages_data} from "./all_messages_data";
import * as blueslip from "./blueslip";
import * as compose from "./compose";
import * as compose_ui from "./compose_ui";
import * as drafts from "./drafts";
import * as local_message from "./local_message";
import * as markdown from "./markdown";
import * as message_events from "./message_events";
import * as message_list from "./message_list";
import * as message_lists from "./message_lists";
import * as message_store from "./message_store";
import * as narrow_state from "./narrow_state";
import * as notifications from "./notifications";
import {page_params} from "./page_params";
import * as people from "./people";
import * as pm_list from "./pm_list";
import * as popovers from "./popovers";
import * as recent_topics_data from "./recent_topics_data";
import * as rows from "./rows";
import * as sent_messages from "./sent_messages";
import * as stream_list from "./stream_list";
import * as stream_topic_history from "./stream_topic_history";
import * as transmit from "./transmit";
import * as ui from "./ui";
import * as util from "./util";
// Docs: https://zulip.readthedocs.io/en/latest/subsystems/sending-messages.html
const waiting_for_id = new Map();
let waiting_for_ack = new Map();
// These retry spinner functions return true if and only if the
// spinner already is in the requested state, which can be used to
// avoid sending duplicate requests.
function show_retry_spinner($row) {
const $retry_spinner = $row.find(".refresh-failed-message");
if (!$retry_spinner.hasClass("rotating")) {
$retry_spinner.toggleClass("rotating", true);
return false;
}
return true;
}
function hide_retry_spinner($row) {
const $retry_spinner = $row.find(".refresh-failed-message");
if ($retry_spinner.hasClass("rotating")) {
$retry_spinner.toggleClass("rotating", false);
return false;
}
return true;
}
function insert_message(message) {
// It is a little bit funny to go through the message_events
// codepath, but it's sort of the idea behind local echo that
// we are simulating server events before they actually arrive.
message_events.insert_new_messages([message], true);
}
function failed_message_success(message_id) {
message_store.get(message_id).failed_request = false;
ui.show_failed_message_success(message_id);
}
function resend_message(message, $row) {
message.content = message.raw_content;
if (show_retry_spinner($row)) {
// retry already in in progress
return;
}
// Always re-set queue_id if we've gotten a new one
// since the time when the message object was initially created
message.queue_id = page_params.queue_id;
const local_id = message.local_id;
function on_success(data) {
const message_id = data.id;
const locally_echoed = true;
hide_retry_spinner($row);
compose.send_message_success(local_id, message_id, locally_echoed);
// Resend succeeded, so mark as no longer failed
failed_message_success(message_id);
}
function on_error(response) {
message_send_error(message.id, response);
setTimeout(() => {
hide_retry_spinner($row);
}, 300);
blueslip.log("Manual resend of message failed");
}
sent_messages.start_resend(local_id);
transmit.send_message(message, on_success, on_error);
}
export function build_display_recipient(message) {
if (message.type === "stream") {
return message.stream;
}
// Build a display recipient with the full names of each
// recipient. Note that it's important that use
// util.extract_pm_recipients, which filters out any spurious
// ", " at the end of the recipient list
const emails = util.extract_pm_recipients(message.private_message_recipient);
let sender_in_display_recipients = false;
const display_recipient = emails.map((email) => {
email = email.trim();
const person = people.get_by_email(email);
if (person === undefined) {
// For unknown users, we return a skeleton object.
//
// This allows us to support zephyr mirroring situations
// where the server might dynamically create users in
// response to messages being sent to their email address.
//
// TODO: It might be cleaner for the web app for such
// dynamic user creation to happen inside a separate API
// call when the pill is constructed, and then enforcing
// the requirement that we have an actual user object in
// `people.js` when sending messages.
return {
email,
full_name: email,
unknown_local_echo_user: true,
};
}
if (person.user_id === message.sender_id) {
sender_in_display_recipients = true;
}
// NORMAL PATH
//
// This should match the format of display_recipient
// objects generated by the backend code in models.py,
// which is why we create a new object with a `.id` field
// rather than a `.user_id` field.
return {
id: person.user_id,
email: person.email,
full_name: person.full_name,
};
});
if (!sender_in_display_recipients) {
// Ensure that the current user is included in
// display_recipient for group PMs.
display_recipient.push({
id: message.sender_id,
email: message.sender_email,
full_name: message.sender_full_name,
});
}
return display_recipient;
}
export function insert_local_message(message_request, local_id_float) {
// 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
const message = {...message_request};
message.raw_content = message.content;
// NOTE: This will parse synchronously. We're not using the async pipeline
markdown.apply_markdown(message);
message.content_type = "text/html";
message.sender_email = people.my_current_email();
message.sender_full_name = people.my_full_name();
message.avatar_url = page_params.avatar_url;
message.timestamp = Date.now() / 1000;
message.local_id = local_id_float.toString();
message.locally_echoed = true;
message.id = local_id_float;
markdown.add_topic_links(message);
waiting_for_id.set(message.local_id, message);
waiting_for_ack.set(message.local_id, message);
message.display_recipient = build_display_recipient(message);
insert_message(message);
return message;
}
export function is_slash_command(content) {
return !content.startsWith("/me") && content.startsWith("/");
}
export function try_deliver_locally(message_request) {
if (markdown.contains_backend_only_syntax(message_request.content)) {
return undefined;
}
if (narrow_state.active() && !narrow_state.filter().can_apply_locally(true)) {
return undefined;
}
if (is_slash_command(message_request.content)) {
return undefined;
}
if (!message_lists.current.data.fetch_status.has_found_newest()) {
// If the current message list doesn't yet have the latest
// messages before the one we just sent, local echo would make
// it appear as though there were no messages between what we
// have and the new message we just sent, when in fact we're
// in the process of fetching those from the server. In this
// case, it's correct to skip local echo; we'll get the
// message we just sent placed appropriately when we get it
// from either server_events or message_fetch.
blueslip.info("Skipping local echo until newest messages get loaded.");
return undefined;
}
const local_id_float = local_message.get_next_id_float();
if (!local_id_float) {
// This can happen for legit reasons.
return undefined;
}
// Save a locally echoed message in drafts, so it cannot be
// lost. It will be cleared if the message is sent successfully.
// We ask the drafts system to not notify the user, since they'd
// be quite distracting in the very common case that the message
// sends normally.
const draft_id = drafts.update_draft({no_notify: true});
message_request.draft_id = draft_id;
// Now that we've committed to delivering the message locally, we
// shrink the compose-box if it is in the full-screen state. This
// would have happened anyway in clear_compose_box, however, we
// need to this operation before inserting the local message into
// the feed. Otherwise, the out-of-view notification will be
// always triggered on the top of compose-box, regardless of
// whether the message would be visible after shrinking compose,
// because compose occludes the whole screen.
if (compose_ui.is_full_size()) {
compose_ui.make_compose_box_original_size();
}
const message = insert_local_message(message_request, local_id_float);
return message;
}
export function edit_locally(message, request) {
// Responsible for doing the rendering work of locally editing the
// content of a message. This is used in several code paths:
// * Editing a message where a message was locally echoed but
// it got an error back from the server
// * Locally echoing any content-only edits to fully sent messages
// * Restoring the original content should the server return an
// error after having locally echoed content-only messages.
// The details of what should be changed are encoded in the request.
const raw_content = request.raw_content;
const message_content_edited = raw_content !== undefined && message.raw_content !== raw_content;
if (request.new_topic !== undefined || request.new_stream_id !== undefined) {
const new_stream_id = request.new_stream_id;
const new_topic = request.new_topic;
stream_topic_history.remove_messages({
stream_id: message.stream_id,
topic_name: message.topic,
num_messages: 1,
max_removed_msg_id: message.id,
});
if (new_stream_id !== undefined) {
message.stream_id = new_stream_id;
}
if (new_topic !== undefined) {
message.topic = new_topic;
}
stream_topic_history.add_message({
stream_id: message.stream_id,
topic_name: message.topic,
message_id: message.id,
});
}
if (message_content_edited) {
message.raw_content = raw_content;
if (request.content !== undefined) {
// This happens in the code path where message editing
// failed and we're trying to undo the local echo. We use
// the saved content and flags rather than rendering; this
// is important in case
// markdown.contains_backend_only_syntax(message) is true.
message.content = request.content;
message.mentioned = request.mentioned;
message.mentioned_me_directly = request.mentioned_me_directly;
message.alerted = request.alerted;
} else {
// Otherwise, we Markdown-render the message; this resets
// all flags, so we need to restore those flags that are
// properties of how the user has interacted with the
// message, and not its rendering.
markdown.apply_markdown(message);
if (request.starred !== undefined) {
message.starred = request.starred;
}
if (request.historical !== undefined) {
message.historical = request.historical;
}
if (request.collapsed !== undefined) {
message.collapsed = request.collapsed;
}
}
}
// We don't have logic to adjust unread counts, because message
// reaching this code path must either have been sent by us or the
// topic isn't being edited, so unread counts can't have changed.
message_lists.home.view.rerender_messages([message]);
if (message_lists.current === message_list.narrowed) {
message_list.narrowed.view.rerender_messages([message]);
}
stream_list.update_streams_sidebar();
pm_list.update_private_messages();
}
export function reify_message_id(local_id, server_id) {
const message = waiting_for_id.get(local_id);
waiting_for_id.delete(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;
}
message.id = server_id;
message.locally_echoed = false;
if (message.draft_id) {
// Delete the draft if message was locally echoed
drafts.draft_model.deleteDraft(message.draft_id);
}
const opts = {old_id: Number.parseFloat(local_id), new_id: server_id};
message_store.reify_message_id(opts);
update_message_lists(opts);
notifications.reify_message_id(opts);
recent_topics_data.reify_message_id_if_available(opts);
}
export function update_message_lists({old_id, new_id}) {
if (all_messages_data !== undefined) {
all_messages_data.change_message_id(old_id, new_id);
}
for (const msg_list of [message_lists.home, message_list.narrowed]) {
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);
}
}
}
}
export function process_from_server(messages) {
const msgs_to_rerender = [];
const non_echo_messages = [];
for (const message of messages) {
// In case we get the sent message before we get the send ACK, reify here
const local_id = message.local_id;
const client_message = waiting_for_ack.get(local_id);
if (client_message === undefined) {
// For messages that weren't locally echoed, we go through
// the "main" codepath that doesn't have to id reconciliation.
// We simply return non-echo messages to our caller.
non_echo_messages.push(message);
continue;
}
reify_message_id(local_id, message.id);
if (message_store.get(message.id).failed_request) {
failed_message_success(message.id);
}
if (client_message.content !== message.content) {
client_message.content = message.content;
sent_messages.mark_disparity(local_id);
}
message_store.update_booleans(client_message, message.flags);
// We don't try to highlight alert words locally, so we have to
// do it now. (Note that we will indeed highlight alert words in
// messages that we sent to ourselves, since we might want to test
// that our alert words are set up correctly.)
alert_words.process_message(client_message);
// Previously, the message had the "local echo" timestamp set
// by the browser; if there was some round-trip delay to the
// server, the actual server-side timestamp could be slightly
// different. This corrects the frontend timestamp to match
// the backend.
client_message.timestamp = message.timestamp;
client_message.topic_links = message.topic_links;
client_message.is_me_message = message.is_me_message;
client_message.submessages = message.submessages;
msgs_to_rerender.push(client_message);
waiting_for_ack.delete(local_id);
}
if (msgs_to_rerender.length > 0) {
// In theory, we could just rerender messages where there were
// changes in either the rounded timestamp we display or the
// message content, but in practice, there's no harm to just
// doing it unconditionally.
message_lists.home.view.rerender_messages(msgs_to_rerender);
if (message_lists.current === message_list.narrowed) {
message_list.narrowed.view.rerender_messages(msgs_to_rerender);
}
}
return non_echo_messages;
}
export function _patch_waiting_for_ack(data) {
// Only for testing
waiting_for_ack = data;
}
export function message_send_error(message_id, error_response) {
// Error sending message, show inline
message_store.get(message_id).failed_request = true;
ui.show_message_failed(message_id, error_response);
}
function abort_message(message) {
// Remove in all lists in which it exists
all_messages_data.remove([message.id]);
for (const msg_list of [message_lists.home, message_lists.current]) {
msg_list.remove_and_rerender([message.id]);
}
}
export function initialize() {
function on_failed_action(selector, callback) {
$("#main_div").on("click", selector, function (e) {
e.stopPropagation();
popovers.hide_all();
const $row = $(this).closest(".message_row");
const local_id = rows.local_echo_id($row);
// Message should be waiting for ack and only have a local id,
// otherwise send would not have failed
const message = waiting_for_ack.get(local_id);
if (message === undefined) {
blueslip.warn(
"Got resend or retry on failure request but did not find message in ack list " +
local_id,
);
return;
}
callback(message, $row);
});
}
on_failed_action(".remove-failed-message", abort_message);
on_failed_action(".refresh-failed-message", resend_message);
}