mirror of
				https://github.com/zulip/zulip.git
				synced 2025-11-04 05:53:43 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			553 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			553 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
import $ from "jquery";
 | 
						|
import _ from "lodash";
 | 
						|
 | 
						|
import * as emoji from "../shared/js/emoji";
 | 
						|
import render_message_reaction from "../templates/message_reaction.hbs";
 | 
						|
 | 
						|
import * as blueslip from "./blueslip";
 | 
						|
import * as channel from "./channel";
 | 
						|
import * as emoji_picker from "./emoji_picker";
 | 
						|
import * as message_store from "./message_store";
 | 
						|
import * as people from "./people";
 | 
						|
 | 
						|
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 = current_msg_list.selected_message();
 | 
						|
    let target;
 | 
						|
 | 
						|
    // Use verbose style to ensure we test both sides of the condition.
 | 
						|
    if (message.sent_by_me) {
 | 
						|
        target = $(current_msg_list.selected_row()).find(".actions_hover")[0];
 | 
						|
    } else {
 | 
						|
        target = $(current_msg_list.selected_row()).find(".reaction_button")[0];
 | 
						|
    }
 | 
						|
 | 
						|
    emoji_picker.toggle_emoji_popover(target, current_msg_list.selected_id());
 | 
						|
    return true;
 | 
						|
}
 | 
						|
 | 
						|
export function current_user_has_reacted_to_emoji(message, local_id) {
 | 
						|
    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;
 | 
						|
    }
 | 
						|
 | 
						|
    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) {
 | 
						|
    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_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);
 | 
						|
}
 | 
						|
 | 
						|
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 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.
 | 
						|
export function get_reaction_title_data(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;
 | 
						|
}
 | 
						|
 | 
						|
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_count(reaction, count) {
 | 
						|
    const count_element = reaction.find(".message_reaction_count");
 | 
						|
    count_element.text(count);
 | 
						|
}
 | 
						|
 | 
						|
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;
 | 
						|
 | 
						|
    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);
 | 
						|
        update_user_fields(r);
 | 
						|
    } else {
 | 
						|
        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;
 | 
						|
        view.update_existing_reaction(opts);
 | 
						|
    } else {
 | 
						|
        view.insert_new_reaction(opts);
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
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 = get_local_reaction_id(opts);
 | 
						|
    const reaction = find_reaction(message_id, local_id);
 | 
						|
 | 
						|
    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");
 | 
						|
    }
 | 
						|
};
 | 
						|
 | 
						|
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 = 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 = get_add_reaction_button(message_id);
 | 
						|
    new_reaction.insertBefore(reaction_button_element);
 | 
						|
};
 | 
						|
 | 
						|
export function remove_reaction(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 = 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 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) {
 | 
						|
        update_user_fields(r);
 | 
						|
    } else {
 | 
						|
        message.clean_reactions.delete(local_id);
 | 
						|
    }
 | 
						|
 | 
						|
    view.remove_reaction({
 | 
						|
        message_id,
 | 
						|
        reaction_type,
 | 
						|
        emoji_name,
 | 
						|
        emoji_code,
 | 
						|
        user_list: r.user_ids,
 | 
						|
        user_id,
 | 
						|
    });
 | 
						|
}
 | 
						|
 | 
						|
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 = get_local_reaction_id(opts);
 | 
						|
    const reaction = 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.
 | 
						|
 | 
						|
    set_reaction_count(reaction, user_list.length);
 | 
						|
 | 
						|
    if (user_id === page_params.user_id) {
 | 
						|
        reaction.removeClass("reacted");
 | 
						|
    }
 | 
						|
};
 | 
						|
 | 
						|
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 r of message.clean_reactions.values()) {
 | 
						|
        if (r.user_ids.includes(user_id)) {
 | 
						|
            names.push(r.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) {
 | 
						|
    /*
 | 
						|
        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 = 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);
 | 
						|
 | 
						|
        add_clean_reaction({
 | 
						|
            message,
 | 
						|
            local_id,
 | 
						|
            user_ids,
 | 
						|
            reaction_type: reaction.reaction_type,
 | 
						|
            emoji_name: reaction.emoji_name,
 | 
						|
            emoji_code: reaction.emoji_code,
 | 
						|
        });
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
export function add_clean_reaction(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;
 | 
						|
    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);
 | 
						|
}
 | 
						|
 | 
						|
export function update_user_fields(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";
 | 
						|
    }
 | 
						|
}
 |