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",