mirror of https://github.com/zulip/zulip.git
584 lines
21 KiB
JavaScript
584 lines
21 KiB
JavaScript
import $ from "jquery";
|
|
|
|
import render_message_reaction from "../templates/message_reaction.hbs";
|
|
|
|
import * as blueslip from "./blueslip";
|
|
import * as channel from "./channel";
|
|
import * as emoji from "./emoji";
|
|
import * as emoji_picker from "./emoji_picker";
|
|
import {$t} from "./i18n";
|
|
import * as message_lists from "./message_lists";
|
|
import * as message_store from "./message_store";
|
|
import {page_params} from "./page_params";
|
|
import * as people from "./people";
|
|
import * as spectators from "./spectators";
|
|
import {user_settings} from "./user_settings";
|
|
|
|
export const view = {}; // function namespace
|
|
|
|
export function get_local_reaction_id(reaction_info) {
|
|
return [reaction_info.reaction_type, reaction_info.emoji_code].join(",");
|
|
}
|
|
|
|
export function open_reactions_popover() {
|
|
const message = message_lists.current.selected_message();
|
|
let target;
|
|
|
|
// Use verbose style to ensure we test both sides of the condition.
|
|
if (message.sent_by_me) {
|
|
target = $(message_lists.current.selected_row()).find(".actions_hover")[0];
|
|
} else {
|
|
target = $(message_lists.current.selected_row()).find(".reaction_button")[0];
|
|
}
|
|
|
|
emoji_picker.toggle_emoji_popover(target, message_lists.current.selected_id());
|
|
return true;
|
|
}
|
|
|
|
export function current_user_has_reacted_to_emoji(message, local_id) {
|
|
set_clean_reactions(message);
|
|
|
|
const clean_reaction_object = message.clean_reactions.get(local_id);
|
|
return clean_reaction_object && clean_reaction_object.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;
|
|
}
|
|
|
|
set_clean_reactions(message);
|
|
return message;
|
|
}
|
|
|
|
function create_reaction(message_id, reaction_info) {
|
|
return {
|
|
message_id,
|
|
user_id: page_params.user_id,
|
|
local_id: 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) {
|
|
if (page_params.is_spectator) {
|
|
// Spectators can't react, since they don't have accounts. We
|
|
// stop here to avoid a confusing reaction local echo.
|
|
spectators.login_to_access();
|
|
return;
|
|
}
|
|
|
|
const message = get_message(message_id);
|
|
const local_id = get_local_reaction_id(reaction_info);
|
|
const has_reacted = 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") {
|
|
add_reaction(reaction);
|
|
} else {
|
|
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);
|
|
}
|
|
}
|
|
|
|
export function toggle_emoji_reaction(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.get_emoji_details_by_name(emoji_name);
|
|
update_ui_and_send_reaction_ajax(message_id, reaction_info);
|
|
}
|
|
|
|
export function process_reaction_click(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 clean_reaction_object = message.clean_reactions.get(local_id);
|
|
|
|
if (!clean_reaction_object) {
|
|
blueslip.error(
|
|
"Data integrity problem for reaction " + local_id + " (message " + message_id + ")",
|
|
);
|
|
return;
|
|
}
|
|
|
|
const reaction_info = {
|
|
reaction_type: clean_reaction_object.reaction_type,
|
|
emoji_name: clean_reaction_object.emoji_name,
|
|
emoji_code: clean_reaction_object.emoji_code,
|
|
};
|
|
|
|
update_ui_and_send_reaction_ajax(message_id, reaction_info);
|
|
}
|
|
|
|
function generate_title(emoji_name, user_ids) {
|
|
const usernames = people.get_display_full_names(
|
|
user_ids.filter((user_id) => user_id !== page_params.user_id),
|
|
);
|
|
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 $t({defaultMessage: "You (click to remove) reacted with {emoji_name}"}, context);
|
|
}
|
|
context.username = usernames[0];
|
|
return $t({defaultMessage: "{username} reacted with {emoji_name}"}, context);
|
|
}
|
|
|
|
if (user_ids.length === 2 && current_user_reacted) {
|
|
context.other_username = usernames[0];
|
|
return $t(
|
|
{
|
|
defaultMessage:
|
|
"You (click to remove) and {other_username} reacted with {emoji_name}",
|
|
},
|
|
context,
|
|
);
|
|
}
|
|
|
|
context.comma_separated_usernames = usernames.slice(0, -1).join(", ");
|
|
context.last_username = usernames.at(-1);
|
|
if (current_user_reacted) {
|
|
return $t(
|
|
{
|
|
defaultMessage:
|
|
"You (click to remove), {comma_separated_usernames} and {last_username} reacted with {emoji_name}",
|
|
},
|
|
context,
|
|
);
|
|
}
|
|
return $t(
|
|
{
|
|
defaultMessage:
|
|
"{comma_separated_usernames} and {last_username} reacted with {emoji_name}",
|
|
},
|
|
context,
|
|
);
|
|
}
|
|
|
|
// Add a tooltip showing who reacted to a message.
|
|
export function get_reaction_title_data(message_id, local_id) {
|
|
const message = get_message(message_id);
|
|
|
|
const clean_reaction_object = message.clean_reactions.get(local_id);
|
|
const user_list = clean_reaction_object.user_ids;
|
|
const emoji_name = clean_reaction_object.emoji_name;
|
|
const title = generate_title(emoji_name, user_list);
|
|
|
|
return title;
|
|
}
|
|
|
|
export function get_reaction_section(message_id) {
|
|
const $message_element = $(".message_table").find(`[zid='${CSS.escape(message_id)}']`);
|
|
const $section = $message_element.find(".message_reactions");
|
|
return $section;
|
|
}
|
|
|
|
export function find_reaction(message_id, local_id) {
|
|
const $reaction_section = get_reaction_section(message_id);
|
|
const $reaction = $reaction_section.find(`[data-reaction-id='${CSS.escape(local_id)}']`);
|
|
return $reaction;
|
|
}
|
|
|
|
export function get_add_reaction_button(message_id) {
|
|
const $reaction_section = get_reaction_section(message_id);
|
|
const $add_button = $reaction_section.find(".reaction_button");
|
|
return $add_button;
|
|
}
|
|
|
|
export function set_reaction_vote_text($reaction, vote_text) {
|
|
const $count_element = $reaction.find(".message_reaction_count");
|
|
$count_element.text(vote_text);
|
|
}
|
|
|
|
export function add_reaction(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;
|
|
}
|
|
|
|
set_clean_reactions(message);
|
|
|
|
const local_id = get_local_reaction_id(event);
|
|
const user_id = event.user_id;
|
|
let clean_reaction_object = message.clean_reactions.get(local_id);
|
|
if (clean_reaction_object && clean_reaction_object.user_ids.includes(user_id)) {
|
|
return;
|
|
}
|
|
|
|
if (clean_reaction_object) {
|
|
clean_reaction_object.user_ids.push(user_id);
|
|
update_user_fields(clean_reaction_object, message.clean_reactions);
|
|
view.update_existing_reaction(clean_reaction_object, message, user_id);
|
|
} else {
|
|
clean_reaction_object = make_clean_reaction({
|
|
local_id,
|
|
user_ids: [user_id],
|
|
reaction_type: event.reaction_type,
|
|
emoji_name: event.emoji_name,
|
|
emoji_code: event.emoji_code,
|
|
});
|
|
|
|
message.clean_reactions.set(local_id, clean_reaction_object);
|
|
update_user_fields(clean_reaction_object, message.clean_reactions);
|
|
view.insert_new_reaction(clean_reaction_object, message, user_id);
|
|
}
|
|
}
|
|
|
|
view.update_existing_reaction = function (clean_reaction_object, message, acting_user_id) {
|
|
// 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 local_id = get_local_reaction_id(clean_reaction_object);
|
|
const $reaction = find_reaction(message.id, local_id);
|
|
|
|
const new_label = generate_title(
|
|
clean_reaction_object.emoji_name,
|
|
clean_reaction_object.user_ids,
|
|
);
|
|
$reaction.attr("aria-label", new_label);
|
|
|
|
if (acting_user_id === page_params.user_id) {
|
|
$reaction.addClass("reacted");
|
|
}
|
|
|
|
update_vote_text_on_message(message);
|
|
};
|
|
|
|
view.insert_new_reaction = function (clean_reaction_object, message, user_id) {
|
|
// Our caller ensures we are the first user to react to this
|
|
// message with this emoji. We then render the emoji/title/count
|
|
// and insert it before the add button.
|
|
|
|
const context = {
|
|
message_id: message.id,
|
|
...emoji.get_emoji_details_for_rendering(clean_reaction_object),
|
|
};
|
|
|
|
const new_label = generate_title(
|
|
clean_reaction_object.emoji_name,
|
|
clean_reaction_object.user_ids,
|
|
);
|
|
|
|
context.count = 1;
|
|
context.label = new_label;
|
|
context.local_id = get_local_reaction_id(clean_reaction_object);
|
|
context.emoji_alt_code = user_settings.emojiset === "text";
|
|
context.is_realm_emoji =
|
|
context.reaction_type === "realm_emoji" || context.reaction_type === "zulip_extra_emoji";
|
|
|
|
if (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 = get_add_reaction_button(message.id);
|
|
$new_reaction.insertBefore($reaction_button_element);
|
|
|
|
update_vote_text_on_message(message);
|
|
};
|
|
|
|
export function remove_reaction(event) {
|
|
const message_id = event.message_id;
|
|
const user_id = event.user_id;
|
|
const message = message_store.get(message_id);
|
|
const local_id = 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;
|
|
}
|
|
|
|
set_clean_reactions(message);
|
|
|
|
const clean_reaction_object = message.clean_reactions.get(local_id);
|
|
|
|
if (!clean_reaction_object) {
|
|
return;
|
|
}
|
|
|
|
if (!clean_reaction_object.user_ids.includes(user_id)) {
|
|
return;
|
|
}
|
|
|
|
clean_reaction_object.user_ids = clean_reaction_object.user_ids.filter((id) => id !== user_id);
|
|
if (clean_reaction_object.user_ids.length === 0) {
|
|
message.clean_reactions.delete(local_id);
|
|
}
|
|
|
|
const should_display_reactors = check_should_display_reactors(message.clean_reactions);
|
|
update_user_fields(clean_reaction_object, should_display_reactors);
|
|
|
|
view.remove_reaction(clean_reaction_object, message, user_id);
|
|
}
|
|
|
|
view.remove_reaction = function (clean_reaction_object, message, user_id) {
|
|
const local_id = get_local_reaction_id(clean_reaction_object);
|
|
const $reaction = find_reaction(message.id, local_id);
|
|
const reaction_count = clean_reaction_object.user_ids.length;
|
|
|
|
if (reaction_count === 0) {
|
|
// If this user was the only one reacting for this emoji, we simply
|
|
// remove the reaction and exit.
|
|
$reaction.remove();
|
|
update_vote_text_on_message(message);
|
|
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(
|
|
clean_reaction_object.emoji_name,
|
|
clean_reaction_object.user_ids,
|
|
);
|
|
$reaction.attr("aria-label", new_label);
|
|
if (user_id === page_params.user_id) {
|
|
$reaction.removeClass("reacted");
|
|
}
|
|
|
|
update_vote_text_on_message(message);
|
|
};
|
|
|
|
export function get_emojis_used_by_user_for_message_id(message_id) {
|
|
const user_id = page_params.user_id;
|
|
const message = message_store.get(message_id);
|
|
set_clean_reactions(message);
|
|
|
|
const names = [];
|
|
for (const clean_reaction_object of message.clean_reactions.values()) {
|
|
if (clean_reaction_object.user_ids.includes(user_id)) {
|
|
names.push(clean_reaction_object.emoji_name);
|
|
}
|
|
}
|
|
|
|
return names;
|
|
}
|
|
|
|
export function get_message_reactions(message) {
|
|
set_clean_reactions(message);
|
|
return Array.from(message.clean_reactions.values());
|
|
}
|
|
|
|
export function set_clean_reactions(message) {
|
|
/*
|
|
set_clean_reactions processes the raw message.reactions object,
|
|
which will contain one object for each individual reaction, even
|
|
if two users react with the same emoji.
|
|
|
|
As output, it sets message.cleaned_reactions, which is a more
|
|
compressed format with one entry per reaction pill that should
|
|
be displayed visually to users.
|
|
*/
|
|
|
|
if (message.clean_reactions) {
|
|
// Update display details for the reaction. In particular,
|
|
// user_settings.display_emoji_reaction_users or the names of
|
|
// the users appearing in the reaction may have changed since
|
|
// this reaction was first rendered.
|
|
const should_display_reactors = check_should_display_reactors(message.clean_reactions);
|
|
for (const clean_reaction of message.clean_reactions.values()) {
|
|
update_user_fields(clean_reaction, should_display_reactors);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// This first loop creates a temporary distinct_reactions data
|
|
// structure, which will accumulate the set of users who have
|
|
// reacted with each distinct reaction.
|
|
const distinct_reactions = new Map();
|
|
const user_map = new Map();
|
|
for (const reaction of message.reactions) {
|
|
const local_id = 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);
|
|
}
|
|
|
|
// TODO: Rather than adding this field to the message object, it
|
|
// might be cleaner to create an independent map from message_id
|
|
// => clean_reactions data for the message, with care being taken
|
|
// to make sure reify_message_id moves the data structure
|
|
// properly.
|
|
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);
|
|
|
|
message.clean_reactions.set(
|
|
local_id,
|
|
make_clean_reaction({local_id, user_ids, ...reaction}),
|
|
);
|
|
}
|
|
|
|
// We do update_user_fields in a separate loop, because doing so
|
|
// lets us avoid duplicating check_should_display_reactors to
|
|
// determine whether to store in the vote_text field a count or
|
|
// the names of reactors (users who reacted).
|
|
const should_display_reactors = check_should_display_reactors(message.clean_reactions);
|
|
for (const clean_reaction of message.clean_reactions.values()) {
|
|
update_user_fields(clean_reaction, should_display_reactors);
|
|
}
|
|
|
|
// We don't maintain message.reactions when users react to
|
|
// messages we already have a copy of, so it's safest to delete it
|
|
// after we've processed the reactions data for a message into the
|
|
// clean_reactions data structure, which we do maintain.
|
|
delete message.reactions;
|
|
}
|
|
|
|
function make_clean_reaction({local_id, user_ids, emoji_name, emoji_code, reaction_type}) {
|
|
const clean_reaction_object = {
|
|
local_id,
|
|
user_ids,
|
|
...emoji.get_emoji_details_for_rendering({emoji_name, emoji_code, reaction_type}),
|
|
};
|
|
clean_reaction_object.emoji_alt_code = user_settings.emojiset === "text";
|
|
clean_reaction_object.is_realm_emoji =
|
|
clean_reaction_object.reaction_type === "realm_emoji" ||
|
|
clean_reaction_object.reaction_type === "zulip_extra_emoji";
|
|
return clean_reaction_object;
|
|
}
|
|
|
|
export function update_user_fields(clean_reaction_object, should_display_reactors) {
|
|
// update_user_fields needs to be called whenever the set of users
|
|
// whor eacted on a message might have changed, including due to
|
|
// upvote/downvotes on ANY reaction in the message, because those
|
|
// can change the correct value of should_display_reactors to use.
|
|
clean_reaction_object.count = clean_reaction_object.user_ids.length;
|
|
clean_reaction_object.label = generate_title(
|
|
clean_reaction_object.emoji_name,
|
|
clean_reaction_object.user_ids,
|
|
);
|
|
if (clean_reaction_object.user_ids.includes(page_params.user_id)) {
|
|
clean_reaction_object.class = "message_reaction reacted";
|
|
} else {
|
|
clean_reaction_object.class = "message_reaction";
|
|
}
|
|
|
|
clean_reaction_object.count = clean_reaction_object.user_ids.length;
|
|
clean_reaction_object.label = generate_title(
|
|
clean_reaction_object.emoji_name,
|
|
clean_reaction_object.user_ids,
|
|
);
|
|
|
|
// The vote_text field set here is used directly in the Handlebars
|
|
// template for rendering (or rerendering!) a message.
|
|
clean_reaction_object.vote_text = get_vote_text(clean_reaction_object, should_display_reactors);
|
|
}
|
|
|
|
export function get_vote_text(clean_reaction_object, should_display_reactors) {
|
|
if (should_display_reactors) {
|
|
return comma_separated_usernames(clean_reaction_object.user_ids);
|
|
}
|
|
return `${clean_reaction_object.user_ids.length}`;
|
|
}
|
|
|
|
function check_should_display_reactors(cleaned_reactions) {
|
|
if (!user_settings.display_emoji_reaction_users) {
|
|
return false;
|
|
}
|
|
|
|
let total_reactions = 0;
|
|
for (const r of cleaned_reactions.values()) {
|
|
// r.count is not yet initialized when this is called during
|
|
// set_clean_reactions.
|
|
total_reactions += r.count || r.user_ids.length;
|
|
}
|
|
return total_reactions <= 3;
|
|
}
|
|
|
|
function comma_separated_usernames(user_list) {
|
|
const usernames = people.get_display_full_names(user_list);
|
|
const current_user_has_reacted = user_list.includes(page_params.user_id);
|
|
|
|
if (current_user_has_reacted) {
|
|
const current_user_index = user_list.indexOf(page_params.user_id);
|
|
usernames[current_user_index] = $t({
|
|
defaultMessage: "You",
|
|
});
|
|
}
|
|
const comma_separated_usernames = usernames.join(", ");
|
|
return comma_separated_usernames;
|
|
}
|
|
|
|
export function update_vote_text_on_message(message) {
|
|
// Because whether we display a count or the names of reacting
|
|
// users depends on total reactions on the message, we need to
|
|
// recalculate this whenever adjusting reaction rendering on a
|
|
// message.
|
|
const cleaned_reactions = get_message_reactions(message);
|
|
const should_display_reactors = check_should_display_reactors(cleaned_reactions);
|
|
for (const [reaction, clean_reaction] of message.clean_reactions.entries()) {
|
|
const reaction_elem = find_reaction(message.id, clean_reaction.local_id);
|
|
const vote_text = get_vote_text(clean_reaction, should_display_reactors);
|
|
message.clean_reactions.get(reaction).vote_text = vote_text;
|
|
set_reaction_vote_text(reaction_elem, vote_text);
|
|
}
|
|
}
|