diff --git a/web/src/message_notifications.js b/web/src/message_notifications.js index 2044a0919cdab7..ff27fe5e2341b2 100644 --- a/web/src/message_notifications.js +++ b/web/src/message_notifications.js @@ -16,6 +16,11 @@ import * as user_topics from "./user_topics"; function get_notification_content(message) { let content; + + if (message.reaction_label) { + return message.reaction_label; + } + // Convert the content to plain text, replacing emoji with their alt text const $content = $("
").html(message.content); ui_util.replace_emoji_with_text($content); @@ -69,6 +74,11 @@ function debug_notification_source_value(message) { function get_notification_key(message) { let key; + if (message.current_reaction_key) { + key = message.current_reaction_key; + return key; + } + if (message.type === "private" || message.type === "test-notification") { key = message.display_reply_to; } else { @@ -89,6 +99,10 @@ function get_notification_title(message, msg_count) { let title = message.sender_full_name; let other_recipients; + if (message.reacted_by) { + title = message.reacted_by; + } + if (msg_count > 1) { title = msg_count + " messages from " + title; } @@ -128,6 +142,7 @@ export function process_notification(notification) { const message = notification.message; const content = get_notification_content(message); const key = get_notification_key(message); + const notification_tag = message.current_reaction_key ? message.id + key : message.id; let notification_object; let msg_count = 1; @@ -146,7 +161,7 @@ export function process_notification(notification) { notification_object = new desktop_notifications.NotificationAPI(title, { icon: icon_url, body: content, - tag: message.id, + tag: notification_tag, }); desktop_notifications.notice_memory.set(key, { obj: notification_object, diff --git a/web/src/reaction_notifications.js b/web/src/reaction_notifications.js new file mode 100644 index 00000000000000..29ee7d85deb37d --- /dev/null +++ b/web/src/reaction_notifications.js @@ -0,0 +1,150 @@ +import $ from "jquery"; + +import * as desktop_notifications from "./desktop_notifications"; +import {$t} from "./i18n"; +import * as message_notifications from "./message_notifications"; +import * as message_store from "./message_store"; +import * as people from "./people"; +import {get_local_reaction_id} from "./reactions"; +import * as settings_config from "./settings_config"; +import {current_user} from "./state_data"; +import * as ui_util from "./ui_util"; +import {user_settings} from "./user_settings"; +import * as user_topics from "./user_topics"; + +function generate_notification_title(emoji_name, user_ids) { + const usernames = people.get_display_full_names( + user_ids.filter((user_id) => user_id !== current_user.user_id), + ); + + const current_user_reacted = user_ids.length !== usernames.length; + + const context = { + emoji_name: ":" + emoji_name + ":", + }; + + if (user_ids.length === 1) { + 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 and {other_username} reacted with {emoji_name}.", + }, + context, + ); + } + + context.total_reactions = usernames.length; + context.last_username = usernames.at(-1); + + return $t( + { + defaultMessage: + "{last_username} and {total_reactions} others reacted with {emoji_name}.", + }, + context, + ); +} + +export function reaction_is_notifiable(message) { + // If the current user reacted, no need for notification. + if (message.current_user_reacted) { + return false; + } + + // If the message is not sent by the current user, no need for notification. + if (!message.sent_by_me) { + return false; + } + + // Notify for reactions in private messages if the user has enabled DM reaction notifications. + if (message.type === "private" && user_settings.enable_dm_reaction_notifications) { + return true; + } + + // Notify for reactions in stream messages if the user has set stream reaction notifications to "Always". + if ( + message.type === "stream" && + user_settings.streams_reaction_notification === + settings_config.streams_reaction_notification_values.always.code + ) { + return true; + } + + // Do not notify for reactions in stream messages if the user has set stream reaction notifications to "Never". + if ( + message.type === "stream" && + user_settings.streams_reaction_notification === + settings_config.streams_reaction_notification_values.never.code + ) { + return false; + } + + // Notify for reactions in stream messages if the message's topic is followed by the user + // and the user has set stream reaction notifications to "Followed topics". + if ( + message.type === "stream" && + user_topics.is_topic_followed(message.stream_id, message.topic) && + user_settings.streams_reaction_notification === + settings_config.streams_reaction_notification_values.followed_topics.code + ) { + return true; + } + + // Notify for reactions in stream messages if the message's topic is unmuted by the user + // and the user has set stream reaction notifications to "Unmuted topics". + if ( + message.type === "stream" && + user_topics.is_topic_unmuted(message.stream_id, message.topic) && + user_settings.streams_reaction_notification === + settings_config.streams_reaction_notification_values.unmuted_topics.code + ) { + return true; + } + + // Everything else is on the table; next filter based on notification + // settings. + return false; +} + +export function received_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; + } + + const user_id = event.user_id; + const local_id = get_local_reaction_id(event); + const clean_reaction_object = message.clean_reactions.get(local_id); + + message.current_user_reacted = user_id === current_user.user_id; + message.current_reaction_key = clean_reaction_object.emoji_name; + message.reaction_label = generate_notification_title( + clean_reaction_object.emoji_name, + clean_reaction_object.user_ids, + ); + message.reacted_by = people.get_full_name(user_id); + + if (!reaction_is_notifiable(message)) { + return; + } + + if (message_notifications.should_send_desktop_notification(message)) { + message_notifications.process_notification({ + message, + desktop_notify: desktop_notifications.granted_desktop_notifications_permission(), + }); + } + if (message_notifications.should_send_audible_notification(message)) { + ui_util.play_audio($("#user-notification-sound-audio").get(0)); + } +} diff --git a/web/src/server_events_dispatch.js b/web/src/server_events_dispatch.js index b1be13bb47892b..4d7950d0f3c4ff 100644 --- a/web/src/server_events_dispatch.js +++ b/web/src/server_events_dispatch.js @@ -37,6 +37,7 @@ import * as overlays from "./overlays"; import * as peer_data from "./peer_data"; import * as people from "./people"; import * as pm_list from "./pm_list"; +import {received_reaction} from "./reaction_notifications"; import * as reactions from "./reactions"; import * as realm_icon from "./realm_icon"; import * as realm_logo from "./realm_logo"; @@ -189,6 +190,7 @@ export function dispatch_normal_event(event) { switch (event.op) { case "add": reactions.add_reaction(event); + received_reaction(event); break; case "remove": reactions.remove_reaction(event); diff --git a/web/tests/notifications.test.js b/web/tests/notifications.test.js index 27c853c411dccb..9be38935578b03 100644 --- a/web/tests/notifications.test.js +++ b/web/tests/notifications.test.js @@ -14,6 +14,8 @@ const stream_data = zrequire("stream_data"); const desktop_notifications = zrequire("desktop_notifications"); const message_notifications = zrequire("message_notifications"); +const reaction_notifications = zrequire("reaction_notifications"); +const settings_config = zrequire("settings_config"); // Not muted streams const general = { @@ -36,6 +38,13 @@ const muted = { stream_data.add_sub(general); stream_data.add_sub(muted); +user_topics.update_user_topics( + general.stream_id, + general.name, + "Unmuted topic", + user_topics.all_visibility_policies.UNMUTED, +); + user_topics.update_user_topics( general.stream_id, general.name, @@ -340,6 +349,190 @@ test("message_is_notifiable", () => { assert.equal(message_notifications.message_is_notifiable(message), true); }); +test("reaction_is_notifiable", () => { + // Case 1: Notify me about reactions to my DMs + user_settings.enable_dm_reaction_notifications = true; + let message = { + id: 1, + type: "private", + content: "React to my DM", + sender_id: "1", + to_user_ids: "31", + sent_by_me: true, + locally_echoed: true, + notification_sent: false, + current_user_reacted: false, + }; + assert.equal(message_notifications.should_send_desktop_notification(message), true); + assert.equal(message_notifications.should_send_audible_notification(message), true); + assert.equal(reaction_notifications.reaction_is_notifiable(message), true); + + // Case 2: Notify me about reactions to my message in topics I'm following + user_settings.streams_reaction_notification = + settings_config.streams_reaction_notification_values.followed_topics.code; + message = { + id: 2, + content: "React to my followed topic message", + type: "stream", + stream_id: general.stream_id, + topic: "followed topic", + sent_by_me: true, + notification_sent: false, + current_user_reacted: false, + }; + assert.equal(message_notifications.should_send_desktop_notification(message), true); + assert.equal(message_notifications.should_send_audible_notification(message), true); + assert.equal(reaction_notifications.reaction_is_notifiable(message), true); + + // Case 3: Notify me about reactions to my message in topics I haven't muted + user_settings.streams_reaction_notification = + settings_config.streams_reaction_notification_values.unmuted_topics.code; + user_settings.enable_stream_desktop_notifications = true; + user_settings.enable_stream_audible_notifications = true; + message = { + id: 3, + content: "React to my unmuted topic message", + type: "stream", + stream_id: general.stream_id, + topic: "Unmuted topic", + sent_by_me: true, + notification_sent: false, + current_user_reacted: false, + }; + + assert.equal(reaction_notifications.reaction_is_notifiable(message), true); + + // Case 4: If reactions are in DMs, but notification setting is disabled + user_settings.enable_dm_reaction_notifications = false; + user_settings.streams_reaction_notification = + settings_config.streams_reaction_notification_values.never.code; + + message = { + id: 4, + type: "private", + content: "React to my DM", + sender_id: "1", + to_user_ids: "31", + sent_by_me: true, + locally_echoed: true, + notification_sent: false, + current_user_reacted: false, + }; + assert.equal(reaction_notifications.reaction_is_notifiable(message), false); + + // Case 5: If reactions are in followed topics, but notification setting is disabled + message = { + id: 5, + content: "React to my followed topic message", + type: "stream", + stream_id: general.stream_id, + topic: "followed topic", + sent_by_me: true, + notification_sent: false, + current_user_reacted: false, + }; + assert.equal(reaction_notifications.reaction_is_notifiable(message), false); + + // Case 6: If reactions are in unmuted topics, but notification setting is disabled + message = { + id: 6, + content: "React to my unmuted topic message", + type: "stream", + stream_id: general.stream_id, + topic: "unmuted topic", + sent_by_me: false, + notification_sent: false, + current_user_reacted: false, + }; + assert.equal(reaction_notifications.reaction_is_notifiable(message), false); + + // Case 7: If the reaction is made by the current user, do not notify + // Reset state + user_settings.enable_dm_reaction_notifications = true; + user_settings.streams_reaction_notification = + settings_config.streams_reaction_notification_values.always.code; + + message = { + id: 7, + type: "private", + content: "React to my DM", + sender_id: "1", + to_user_ids: "31", + sent_by_me: true, + locally_echoed: true, + notification_sent: false, + current_user_reacted: true, + }; + assert.equal(reaction_notifications.reaction_is_notifiable(message), false); + + message = { + id: 8, + content: "React to my followed topic message", + type: "stream", + stream_id: general.stream_id, + topic: "followed topic", + sent_by_me: true, + notification_sent: false, + current_user_reacted: true, + }; + assert.equal(reaction_notifications.reaction_is_notifiable(message), false); + + message = { + id: 9, + content: "React to my unmuted topic message", + type: "stream", + stream_id: general.stream_id, + topic: "unmuted topic", + sent_by_me: false, + notification_sent: false, + current_user_reacted: true, + }; + assert.equal(reaction_notifications.reaction_is_notifiable(message), false); + + // Case 8: Notify me about reactions to my message in topics, when notification setting are set to always + // Reset state + user_settings.enable_dm_reaction_notifications = true; + user_settings.streams_reaction_notification = + settings_config.streams_reaction_notification_values.always.code; + + message = { + id: 7, + type: "private", + content: "React to my DM", + sender_id: "1", + to_user_ids: "31", + sent_by_me: true, + locally_echoed: true, + notification_sent: false, + current_user_reacted: false, + }; + assert.equal(reaction_notifications.reaction_is_notifiable(message), true); + + message = { + id: 8, + content: "React to my followed topic message", + type: "stream", + stream_id: general.stream_id, + topic: "followed topic", + sent_by_me: true, + notification_sent: false, + current_user_reacted: false, + }; + assert.equal(reaction_notifications.reaction_is_notifiable(message), true); + + message = { + id: 3, + content: "React to my unmuted topic message", + type: "stream", + stream_id: general.stream_id, + topic: "whatever", + sent_by_me: true, + notification_sent: false, + current_user_reacted: false, + }; + assert.equal(reaction_notifications.reaction_is_notifiable(message), true); +}); + test("basic_notifications", () => { $("
").set_find_results(".emoji", {text: () => ({contents: () => ({unwrap() {}})})}); $("
").set_find_results("span.katex", {each() {}}); diff --git a/web/tests/reactions.test.js b/web/tests/reactions.test.js index d282273fe28b0b..a13935bedbc29b 100644 --- a/web/tests/reactions.test.js +++ b/web/tests/reactions.test.js @@ -59,6 +59,29 @@ const emoji = zrequire("emoji"); const emoji_codes = zrequire("../../static/generated/emoji/emoji_codes.json"); const people = zrequire("people"); const reactions = zrequire("reactions"); +const reaction_notifications = zrequire("reaction_notifications"); +const desktop_notifications = zrequire("desktop_notifications"); +const message_notifications = zrequire("message_notifications"); +const stream_data = zrequire("stream_data"); +const user_topics = zrequire("user_topics"); +const settings_config = zrequire("settings_config"); + +const general = { + subscribed: true, + name: "general", + stream_id: 10, + is_muted: false, + wildcard_mentions_notify: null, +}; + +stream_data.add_sub(general); + +user_topics.update_user_topics( + general.stream_id, + general.name, + "followed topic", + user_topics.all_visibility_policies.FOLLOWED, +); const emoji_params = { realm_emoji: { @@ -1000,6 +1023,85 @@ test("update_existing_reaction (me)", () => { ); }); +run_test("received_reaction", ({override}) => { + const clean_reaction = { + class: "message_reaction reacted", + count: 1, + emoji_alt_code: false, + emoji_name: "8ball", + emoji_code: "1f3b1", + is_realm_emoji: false, + label: "translated: You (click to remove) reacted with :8ball:", + local_id: "unicode_emoji,1f3b1", + user_ids: [cali.user_id], + vote_text: "translated: You", + }; + const message = { + user_id: alice.user_id, + message_id: 2001, + content: "example content", + reaction_type: "unicode_emoji", + type: "stream", + stream_id: general.stream_id, + topic: "followed topic", + emoji_code: "1f3b1", + sent_by_me: true, + clean_reactions: new Map( + Object.entries({ + "unicode_emoji,1f3b1": clean_reaction, + }), + ), + }; + + user_settings.display_emoji_reaction_users = true; + override(message_store, "get", () => message); + + reaction_notifications.received_reaction(message); + assert.deepEqual(message.reaction_label, "translated: Cali reacted with :8ball:."); + + clean_reaction.user_ids.push(alice.user_id); + message.clean_reactions = new Map( + Object.entries({ + "unicode_emoji,1f3b1": clean_reaction, + }), + ); + current_user.user_id = alice.user_id; + reaction_notifications.received_reaction(message); + assert.deepEqual(message.reaction_label, "translated: You and Cali reacted with :8ball:."); + + user_settings.streams_reaction_notification = + settings_config.streams_reaction_notification_values.always.code; + user_settings.enable_followed_topics_reactions_notifications = true; + user_settings.enable_followed_topic_desktop_notifications = true; + user_settings.enable_followed_topic_audible_notifications = true; + user_settings.enable_desktop_notifications = true; + $("#user-notification-sound-audio").get = function () { + return { + play: noop, + }; + }; + + clean_reaction.user_ids.push(bob.user_id); + message.clean_reactions = new Map( + Object.entries({ + "unicode_emoji,1f3b1": clean_reaction, + }), + ); + current_user.user_id = cali.user_id; + reaction_notifications.received_reaction(message); + + assert.deepEqual( + message.reaction_label, + "translated: Bob van Roberts and 2 others reacted with :8ball:.", + ); + + const n = desktop_notifications.get_notifications(); + assert.deepEqual(n.size, 0); + + assert.equal(message_notifications.should_send_desktop_notification(message), true); + assert.equal(message_notifications.should_send_audible_notification(message), true); +}); + test("update_existing_reaction (them)", () => { const clean_reaction_object = { class: "message_reaction",