zulip/static/js/reactions.js

547 lines
17 KiB
JavaScript

"use strict";
const _ = require("lodash");
const emoji = require("../shared/js/emoji");
const render_message_reaction = require("../templates/message_reaction.hbs");
const people = require("./people");
exports.view = {}; // function namespace
exports.get_local_reaction_id = function (reaction_info) {
return [reaction_info.reaction_type, reaction_info.emoji_code].join(",");
};
exports.open_reactions_popover = function () {
const message = current_msg_list.selected_message();
let target = $(current_msg_list.selected_row()).find(".actions_hover")[0];
if (!message.sent_by_me) {
target = $(current_msg_list.selected_row()).find(".reaction_button")[0];
}
emoji_picker.toggle_emoji_popover(target, current_msg_list.selected_id());
return true;
};
exports.current_user_has_reacted_to_emoji = function (message, local_id) {
exports.set_clean_reactions(message);
const r = message.clean_reactions.get(local_id);
return r && r.user_ids.includes(page_params.user_id);
};
function get_message(message_id) {
const message = message_store.get(message_id);
if (!message) {
blueslip.error("reactions: Bad message id: " + message_id);
return undefined;
}
exports.set_clean_reactions(message);
return message;
}
function create_reaction(message_id, reaction_info) {
return {
message_id,
user_id: page_params.user_id,
local_id: exports.get_local_reaction_id(reaction_info),
reaction_type: reaction_info.reaction_type,
emoji_name: reaction_info.emoji_name,
emoji_code: reaction_info.emoji_code,
};
}
function update_ui_and_send_reaction_ajax(message_id, reaction_info) {
const message = get_message(message_id);
const local_id = exports.get_local_reaction_id(reaction_info);
const has_reacted = exports.current_user_has_reacted_to_emoji(message, local_id);
const operation = has_reacted ? "remove" : "add";
const reaction = create_reaction(message_id, reaction_info);
if (operation === "add") {
exports.add_reaction(reaction);
} else {
exports.remove_reaction(reaction);
}
const args = {
url: "/json/messages/" + message_id + "/reactions",
data: reaction_info,
success() {},
error(xhr) {
const response = channel.xhr_error_message("Error sending reaction", xhr);
// Errors are somewhat common here, due to race conditions
// where the user tries to add/remove the reaction when there is already
// an in-flight request. We eventually want to make this a blueslip
// error, rather than a warning, but we need to implement either
// #4291 or #4295 first.
blueslip.warn(response);
},
};
if (operation === "add") {
channel.post(args);
} else if (operation === "remove") {
channel.del(args);
}
}
exports.toggle_emoji_reaction = function (message_id, emoji_name) {
// This codepath doesn't support toggling a deactivated realm emoji.
// Since an user can interact with a deactivated realm emoji only by
// clicking on a reaction and that is handled by `process_reaction_click()`
// method. This codepath is to be used only where there is no chance of an
// user interacting with a deactivated realm emoji like emoji picker.
const reaction_info = {
emoji_name,
};
if (emoji.active_realm_emojis.has(emoji_name)) {
if (emoji_name === "zulip") {
reaction_info.reaction_type = "zulip_extra_emoji";
} else {
reaction_info.reaction_type = "realm_emoji";
}
reaction_info.emoji_code = emoji.active_realm_emojis.get(emoji_name).id;
} else {
const codepoint = emoji.get_emoji_codepoint(emoji_name);
if (codepoint === undefined) {
blueslip.warn("Bad emoji name: " + emoji_name);
return;
}
reaction_info.reaction_type = "unicode_emoji";
reaction_info.emoji_code = codepoint;
}
update_ui_and_send_reaction_ajax(message_id, reaction_info);
};
exports.process_reaction_click = function (message_id, local_id) {
const message = get_message(message_id);
if (!message) {
blueslip.error("message_id for reaction click is unknown: " + message_id);
return;
}
const r = message.clean_reactions.get(local_id);
if (!r) {
blueslip.error(
"Data integrity problem for reaction " + local_id + " (message " + message_id + ")",
);
return;
}
const reaction_info = {
reaction_type: r.reaction_type,
emoji_name: r.emoji_name,
emoji_code: r.emoji_code,
};
update_ui_and_send_reaction_ajax(message_id, reaction_info);
};
function generate_title(emoji_name, user_ids) {
const usernames = user_ids
.filter((user_id) => user_id !== page_params.user_id)
.map((user_id) => people.get_by_user_id(user_id).full_name);
const current_user_reacted = user_ids.length !== usernames.length;
const context = {
emoji_name: ":" + emoji_name + ":",
};
if (user_ids.length === 1) {
if (current_user_reacted) {
return i18n.t("You (click to remove) reacted with __- emoji_name__", context);
}
context.username = usernames[0];
return i18n.t("__- username__ reacted with __- emoji_name__", context);
}
if (user_ids.length === 2 && current_user_reacted) {
context.other_username = usernames[0];
return i18n.t(
"You (click to remove) and __- other_username__ reacted with __- emoji_name__",
context,
);
}
context.comma_separated_usernames = _.initial(usernames).join(", ");
context.last_username = _.last(usernames);
if (current_user_reacted) {
return i18n.t(
"You (click to remove), __- comma_separated_usernames__ and __- last_username__ reacted with __- emoji_name__",
context,
);
}
return i18n.t(
"__- comma_separated_usernames__ and __- last_username__ reacted with __- emoji_name__",
context,
);
}
// Add a tooltip showing who reacted to a message.
exports.get_reaction_title_data = function (message_id, local_id) {
const message = get_message(message_id);
const r = message.clean_reactions.get(local_id);
const user_list = r.user_ids;
const emoji_name = r.emoji_name;
const title = generate_title(emoji_name, user_list);
return title;
};
exports.get_reaction_section = function (message_id) {
const message_element = $(".message_table").find(`[zid='${CSS.escape(message_id)}']`);
const section = message_element.find(".message_reactions");
return section;
};
exports.find_reaction = function (message_id, local_id) {
const reaction_section = exports.get_reaction_section(message_id);
const reaction = reaction_section.find(`[data-reaction-id='${CSS.escape(local_id)}']`);
return reaction;
};
exports.get_add_reaction_button = function (message_id) {
const reaction_section = exports.get_reaction_section(message_id);
const add_button = reaction_section.find(".reaction_button");
return add_button;
};
exports.set_reaction_count = function (reaction, count) {
const count_element = reaction.find(".message_reaction_count");
count_element.text(count);
};
exports.add_reaction = function (event) {
const message_id = event.message_id;
const message = message_store.get(message_id);
if (message === undefined) {
// If we don't have the message in cache, do nothing; if we
// ever fetch it from the server, it'll come with the
// latest reactions attached
return;
}
exports.set_clean_reactions(message);
const local_id = exports.get_local_reaction_id(event);
const user_id = event.user_id;
const r = message.clean_reactions.get(local_id);
if (r && r.user_ids.includes(user_id)) {
return;
}
if (r) {
r.user_ids.push(user_id);
exports.update_user_fields(r);
} else {
exports.add_clean_reaction({
message,
local_id,
user_ids: [user_id],
reaction_type: event.reaction_type,
emoji_name: event.emoji_name,
emoji_code: event.emoji_code,
});
}
const opts = {
message_id,
reaction_type: event.reaction_type,
emoji_name: event.emoji_name,
emoji_code: event.emoji_code,
user_id,
};
if (r) {
opts.user_list = r.user_ids;
exports.view.update_existing_reaction(opts);
} else {
exports.view.insert_new_reaction(opts);
}
};
exports.view.update_existing_reaction = function (opts) {
// Our caller ensures that this message already has a reaction
// for this emoji and sets up our user_list. This function
// simply updates the DOM.
const message_id = opts.message_id;
const emoji_name = opts.emoji_name;
const user_list = opts.user_list;
const user_id = opts.user_id;
const local_id = exports.get_local_reaction_id(opts);
const reaction = exports.find_reaction(message_id, local_id);
exports.set_reaction_count(reaction, user_list.length);
const new_label = generate_title(emoji_name, user_list);
reaction.attr("aria-label", new_label);
if (user_id === page_params.user_id) {
reaction.addClass("reacted");
}
};
exports.view.insert_new_reaction = function (opts) {
// Our caller ensures we are the first user to react to this
// message with this emoji, and it populates user_list for
// us. We then render the emoji/title/count and insert it
// before the add button.
const message_id = opts.message_id;
const emoji_name = opts.emoji_name;
const emoji_code = opts.emoji_code;
const user_id = opts.user_id;
const user_list = [user_id];
const context = {
message_id,
emoji_name,
emoji_code,
};
const new_label = generate_title(emoji_name, user_list);
if (opts.reaction_type !== "unicode_emoji") {
context.is_realm_emoji = true;
const emoji_info = emoji.all_realm_emojis.get(emoji_code);
if (!emoji_info) {
blueslip.error(`Cannot find/insert realm emoji for code '${emoji_code}'.`);
return;
}
context.url = emoji_info.emoji_url;
}
context.count = 1;
context.label = new_label;
context.local_id = exports.get_local_reaction_id(opts);
context.emoji_alt_code = page_params.emojiset === "text";
if (opts.user_id === page_params.user_id) {
context.class = "message_reaction reacted";
} else {
context.class = "message_reaction";
}
const new_reaction = $(render_message_reaction(context));
// Now insert it before the add button.
const reaction_button_element = exports.get_add_reaction_button(message_id);
new_reaction.insertBefore(reaction_button_element);
};
exports.remove_reaction = function (event) {
const reaction_type = event.reaction_type;
const emoji_name = event.emoji_name;
const emoji_code = event.emoji_code;
const message_id = event.message_id;
const user_id = event.user_id;
const message = message_store.get(message_id);
const local_id = exports.get_local_reaction_id(event);
if (message === undefined) {
// If we don't have the message in cache, do nothing; if we
// ever fetch it from the server, it'll come with the
// latest reactions attached
return;
}
exports.set_clean_reactions(message);
const r = message.clean_reactions.get(local_id);
if (!r) {
return;
}
if (!r.user_ids.includes(user_id)) {
return;
}
r.user_ids = r.user_ids.filter((id) => id !== user_id);
if (r.user_ids.length > 0) {
exports.update_user_fields(r);
} else {
message.clean_reactions.delete(local_id);
}
exports.view.remove_reaction({
message_id,
reaction_type,
emoji_name,
emoji_code,
user_list: r.user_ids,
user_id,
});
};
exports.view.remove_reaction = function (opts) {
const message_id = opts.message_id;
const emoji_name = opts.emoji_name;
const user_list = opts.user_list;
const user_id = opts.user_id;
const local_id = exports.get_local_reaction_id(opts);
const reaction = exports.find_reaction(message_id, local_id);
if (user_list.length === 0) {
// If this user was the only one reacting for this emoji, we simply
// remove the reaction and exit.
reaction.remove();
return;
}
// The emoji still has reactions from other users, so we need to update
// the title/count and, if the user is the current user, turn off the
// "reacted" class.
const new_label = generate_title(emoji_name, user_list);
reaction.attr("aria-label", new_label);
// If the user is the current user, turn off the "reacted" class.
exports.set_reaction_count(reaction, user_list.length);
if (user_id === page_params.user_id) {
reaction.removeClass("reacted");
}
};
exports.get_emojis_used_by_user_for_message_id = function (message_id) {
const user_id = page_params.user_id;
const message = message_store.get(message_id);
exports.set_clean_reactions(message);
const names = [];
for (const r of message.clean_reactions.values()) {
if (r.user_ids.includes(user_id)) {
names.push(r.emoji_name);
}
}
return names;
};
exports.get_message_reactions = function (message) {
exports.set_clean_reactions(message);
return Array.from(message.clean_reactions.values());
};
exports.set_clean_reactions = function (message) {
/*
The server sends us a single structure for
each reaction, even if two users are reacting
with the same emoji. Our first loop creates
a map of distinct reactions and a map of
local_id -> user_ids. The `local_id` is
basically a key for the emoji name.
Then in our second loop we build a more compact
data structure that's easier for our message
list view templates to work with.
*/
if (message.clean_reactions) {
return;
}
const distinct_reactions = new Map();
const user_map = new Map();
for (const reaction of message.reactions) {
const local_id = exports.get_local_reaction_id(reaction);
const user_id = reaction.user_id;
if (!people.is_known_user_id(user_id)) {
blueslip.warn("Unknown user_id " + user_id + " in reaction for message " + message.id);
continue;
}
if (!distinct_reactions.has(local_id)) {
distinct_reactions.set(local_id, reaction);
user_map.set(local_id, []);
}
const user_ids = user_map.get(local_id);
if (user_ids.includes(user_id)) {
blueslip.error(
"server sent duplicate reactions for user " + user_id + " (key=" + local_id + ")",
);
continue;
}
user_ids.push(user_id);
}
/*
It might feel a little janky to attach clean_reactions
directly to the message object, but this allows the
server to send us a new copy of the message, and then
the next time we try to get reactions from it, we
won't have `clean_reactions`, and we will re-process
the server's latest copy of the reactions.
*/
message.clean_reactions = new Map();
for (const local_id of distinct_reactions.keys()) {
const reaction = distinct_reactions.get(local_id);
const user_ids = user_map.get(local_id);
exports.add_clean_reaction({
message,
local_id,
user_ids,
reaction_type: reaction.reaction_type,
emoji_name: reaction.emoji_name,
emoji_code: reaction.emoji_code,
});
}
};
exports.add_clean_reaction = function (opts) {
const r = {};
r.reaction_type = opts.reaction_type;
r.emoji_name = opts.emoji_name;
r.emoji_code = opts.emoji_code;
r.local_id = opts.local_id;
r.user_ids = opts.user_ids;
exports.update_user_fields(r);
r.emoji_alt_code = page_params.emojiset === "text";
if (r.reaction_type !== "unicode_emoji") {
r.is_realm_emoji = true;
const emoji_info = emoji.all_realm_emojis.get(r.emoji_code);
if (!emoji_info) {
blueslip.error(`Cannot find/add realm emoji for code '${r.emoji_code}'.`);
return;
}
r.url = emoji_info.emoji_url;
}
opts.message.clean_reactions.set(opts.local_id, r);
};
exports.update_user_fields = function (r) {
r.count = r.user_ids.length;
r.label = generate_title(r.emoji_name, r.user_ids);
if (r.user_ids.includes(page_params.user_id)) {
r.class = "message_reaction reacted";
} else {
r.class = "message_reaction";
}
};
window.reactions = exports;