diff --git a/apps/meteor/app/lib/server/functions/notifications/desktop.ts b/apps/meteor/app/lib/server/functions/notifications/desktop.ts index 9b49f53582063..911b01d185487 100644 --- a/apps/meteor/app/lib/server/functions/notifications/desktop.ts +++ b/apps/meteor/app/lib/server/functions/notifications/desktop.ts @@ -23,6 +23,7 @@ export async function notifyDesktopUser({ duration, notificationMessage, audioNotificationValue, + icon, }: { userId: string; user: AtLeast; @@ -31,6 +32,7 @@ export async function notifyDesktopUser({ duration?: number; notificationMessage: string; audioNotificationValue?: string; + icon?: string; }): Promise { const { title, text, name } = await roomCoordinator .getRoomDirectives(room.t) @@ -40,6 +42,7 @@ export async function notifyDesktopUser({ title: title || '', text, duration, + icon, payload: { _id: '', rid: '', diff --git a/apps/meteor/app/reactions/server/index.ts b/apps/meteor/app/reactions/server/index.ts index 8c5644023dbc8..35c463ffdb3a5 100644 --- a/apps/meteor/app/reactions/server/index.ts +++ b/apps/meteor/app/reactions/server/index.ts @@ -1 +1,2 @@ import './setReaction'; +import './notifications'; diff --git a/apps/meteor/app/reactions/server/notifications.ts b/apps/meteor/app/reactions/server/notifications.ts new file mode 100644 index 0000000000000..3fa2dba2c1acc --- /dev/null +++ b/apps/meteor/app/reactions/server/notifications.ts @@ -0,0 +1,130 @@ +import { Subscriptions, Users } from '@rocket.chat/models'; + +import { callbacks } from '../../../server/lib/callbacks'; +import { i18n } from '../../../server/lib/i18n'; +import { notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../lib/server/lib/notifyListener'; +import { settings } from '../../settings/server'; +import { notifyDesktopUser } from '../../lib/server/functions/notifications/desktop'; +import { emoji } from '../../emoji/server'; +import { SystemLogger } from '../../../server/lib/logger/system'; + +callbacks.add( + 'afterSetReaction', + async (message, { user, reaction, room }) => { + try { + if (settings.get('Troubleshoot_Disable_Notifications') === true) { + return; + } + + if (!message.u?._id || message.u._id === user._id) { + return; + } + + const recipient = await Users.findOneById(message.u._id, { + projection: { + 'active': 1, + 'settings.preferences.receiveReactionNotifications': 1, + 'language': 1, + }, + }); + + if (!recipient || !recipient.active) { + return; + } + + const receiveReactionNotifications = + recipient.settings?.preferences?.receiveReactionNotifications ?? + settings.get('Accounts_Default_User_Preferences_receiveReactionNotifications'); + + if (!receiveReactionNotifications) { + return; + } + + await Subscriptions.incReactionsForRoomIdAndUserIds(room._id, [recipient._id], 1); + void notifyOnSubscriptionChangedByRoomIdAndUserId(room._id, recipient._id); + + const useRealName = settings.get('UI_Use_Real_Name'); + const reactorName = (useRealName && user.name) || user.username; + + const emojione = (emoji.packages as any).emojione; + const emojiActual = + (emojione && + (emoji.list[reaction] as any)?.uc_output && + emojione.convert((emoji.list[reaction] as any).uc_output.toUpperCase())) || + reaction; + const msgText = message.msg || i18n.t('Attachment', { lng: recipient.language }); + + const notificationMessage = i18n.t('Reaction_Notification', { + name: reactorName, + reaction: emojiActual, + message: msgText, + lng: recipient.language || settings.get('Language') || 'en', + }); + + await notifyDesktopUser({ + userId: recipient._id, + user, + message, + room, + notificationMessage, + }); + } catch (e) { + SystemLogger.error({ msg: 'Error sending reaction notification', err: e }); + } + }, + callbacks.priority.LOW, + 'RecordReactionNotification', +); + +callbacks.add( + 'afterUnsetReaction', + async (message, { user, room }) => { + try { + if (settings.get('Troubleshoot_Disable_Notifications') === true) { + return; + } + + if (!message.u?._id || message.u._id === user._id) { + return; + } + + const recipient = await Users.findOneById(message.u._id, { + projection: { + 'active': 1, + 'settings.preferences.receiveReactionNotifications': 1, + }, + }); + + if (!recipient || !recipient.active) { + return; + } + + const receiveReactionNotifications = + recipient.settings?.preferences?.receiveReactionNotifications ?? + settings.get('Accounts_Default_User_Preferences_receiveReactionNotifications'); + + if (!receiveReactionNotifications) { + return; + } + + const recipientId = message.u._id; + + await Subscriptions.updateOne( + { + rid: room._id, + 'u._id': recipientId, + reactions: { $gt: 0 }, + }, + { + $inc: { reactions: -1 }, + }, + ); + + void notifyOnSubscriptionChangedByRoomIdAndUserId(room._id, recipientId); + } catch (e) { + SystemLogger.error({ msg: 'Error handling reaction notification removal', err: e }); + } + }, + callbacks.priority.LOW, + 'RecordReactionNotificationRemoval', +); diff --git a/apps/meteor/client/sidebar/badges/ReactionBadge.tsx b/apps/meteor/client/sidebar/badges/ReactionBadge.tsx new file mode 100644 index 0000000000000..b421e891b1bbc --- /dev/null +++ b/apps/meteor/client/sidebar/badges/ReactionBadge.tsx @@ -0,0 +1,26 @@ +import { SidebarV2ItemBadge, Icon, Box } from '@rocket.chat/fuselage'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +type ReactionBadgeProps = { + title: string; + roomTitle?: string; + total: number; +}; + +const ReactionBadge = ({ title, total, roomTitle }: ReactionBadgeProps) => { + const { t } = useTranslation(); + + return ( + + + {total} + + ); +}; + +export default ReactionBadge; diff --git a/apps/meteor/client/sidebar/badges/SidebarItemBadges.tsx b/apps/meteor/client/sidebar/badges/SidebarItemBadges.tsx index aedfd3610ea9c..e210af556e375 100644 --- a/apps/meteor/client/sidebar/badges/SidebarItemBadges.tsx +++ b/apps/meteor/client/sidebar/badges/SidebarItemBadges.tsx @@ -3,6 +3,7 @@ import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import UnreadBadge from './UnreadBadge'; import InvitationBadge from '../../components/InvitationBadge'; +import ReactionBadge from './ReactionBadge'; import OmnichannelBadges from '../../views/omnichannel/components/OmnichannelBadges'; import { useUnreadDisplay } from '../hooks/useUnreadDisplay'; @@ -17,6 +18,7 @@ const SidebarItemBadges = ({ room, roomTitle }: SidebarItemBadgesProps) => { return ( <> {showUnread && } + {unreadCount.reactions > 0 && } {isOmnichannelRoom(room) && } {isInviteSubscription(room) && } diff --git a/apps/meteor/client/views/account/preferences/PreferencesNotificationsSection.tsx b/apps/meteor/client/views/account/preferences/PreferencesNotificationsSection.tsx index 3384a260e5cb2..96be8d9dad244 100644 --- a/apps/meteor/client/views/account/preferences/PreferencesNotificationsSection.tsx +++ b/apps/meteor/client/views/account/preferences/PreferencesNotificationsSection.tsx @@ -204,6 +204,16 @@ const PreferencesNotificationsSection = () => { )} + + + {t('Receive_Reaction_Notifications')} + } + /> + + ); diff --git a/apps/meteor/client/views/account/preferences/useAccountPreferencesValues.ts b/apps/meteor/client/views/account/preferences/useAccountPreferencesValues.ts index b3d6709cee40e..a7a8529518096 100644 --- a/apps/meteor/client/views/account/preferences/useAccountPreferencesValues.ts +++ b/apps/meteor/client/views/account/preferences/useAccountPreferencesValues.ts @@ -39,6 +39,7 @@ export type AccountPreferencesData = { notificationsSoundVolume?: number; voipRingerVolume?: number; desktopNotificationVoiceCalls?: boolean; + receiveReactionNotifications?: boolean; }; export const useAccountPreferencesValues = (): AccountPreferencesData => { @@ -55,6 +56,7 @@ export const useAccountPreferencesValues = (): AccountPreferencesData => { const receiveLoginDetectionEmail = useUserPreference('receiveLoginDetectionEmail', true); const notifyCalendarEvents = useUserPreference('notifyCalendarEvents'); const enableMobileRinging = useUserPreference('enableMobileRinging'); + const receiveReactionNotifications = useUserPreference('receiveReactionNotifications'); const unreadAlert = useUserPreference('unreadAlert'); const showThreadsInMainChannel = useUserPreference('showThreadsInMainChannel'); @@ -111,5 +113,6 @@ export const useAccountPreferencesValues = (): AccountPreferencesData => { notificationsSoundVolume, voipRingerVolume, desktopNotificationVoiceCalls, + receiveReactionNotifications, }; }; diff --git a/apps/meteor/lib/getSubscriptionUnreadData.ts b/apps/meteor/lib/getSubscriptionUnreadData.ts index f6b03cd0915aa..5c19704bd116a 100644 --- a/apps/meteor/lib/getSubscriptionUnreadData.ts +++ b/apps/meteor/lib/getSubscriptionUnreadData.ts @@ -6,11 +6,13 @@ const getUnreadTitle = ( mentions, threads, groupMentions, + reactions, total, }: { mentions: number; threads: number; groupMentions: number; + reactions: number; total: number; }, t: TFunction, @@ -29,23 +31,27 @@ const getUnreadTitle = ( if (count > 0) { title.push(t('unread_messages_counter', { count })); } + if (reactions) { + title.push(t('reactions_counter', { count: reactions })); + } return title.join(', '); }; export type UnreadData = Pick< SubscriptionWithRoom, - 'alert' | 'userMentions' | 'unread' | 'tunread' | 'tunreadUser' | 'groupMentions' | 'hideMentionStatus' | 'hideUnreadStatus' + 'alert' | 'userMentions' | 'unread' | 'tunread' | 'tunreadUser' | 'groupMentions' | 'reactions' | 'hideMentionStatus' | 'hideUnreadStatus' >; export const getSubscriptionUnreadData = ( - { userMentions, tunreadUser, tunread, unread, groupMentions, hideMentionStatus, hideUnreadStatus, alert }: UnreadData, + { userMentions, tunreadUser, tunread, unread, groupMentions, reactions, hideMentionStatus, hideUnreadStatus, alert }: UnreadData, t: TFunction, ) => { const unreadCount = { mentions: userMentions + (tunreadUser?.length || 0), threads: tunread?.length || 0, groupMentions, + reactions: reactions || 0, total: unread + (tunread?.length || 0), }; diff --git a/apps/meteor/lib/publishFields.ts b/apps/meteor/lib/publishFields.ts index 4ef4b6fe95197..3818b78a1accb 100644 --- a/apps/meteor/lib/publishFields.ts +++ b/apps/meteor/lib/publishFields.ts @@ -17,6 +17,7 @@ export const subscriptionFields = { customFields: 1, userMentions: 1, groupMentions: 1, + reactions: 1, archived: 1, audioNotificationValue: 1, desktopNotifications: 1, diff --git a/apps/meteor/server/settings/accounts.ts b/apps/meteor/server/settings/accounts.ts index 3a279f51b316f..b61b46fbeb7bc 100644 --- a/apps/meteor/server/settings/accounts.ts +++ b/apps/meteor/server/settings/accounts.ts @@ -770,6 +770,12 @@ export const createAccountSettings = () => type: 'string', public: true, }); + + await this.add('Accounts_Default_User_Preferences_receiveReactionNotifications', true, { + type: 'boolean', + public: true, + i18nLabel: 'Receive_Reaction_Notifications', + }); }); await this.section('Avatar', async function () { diff --git a/packages/core-typings/src/ISubscription.ts b/packages/core-typings/src/ISubscription.ts index f2db5a8c49811..020c0fb6e2c1e 100644 --- a/packages/core-typings/src/ISubscription.ts +++ b/packages/core-typings/src/ISubscription.ts @@ -19,6 +19,7 @@ export interface ISubscription extends IRocketChatRecord { alert?: boolean; unread: number; + reactions?: number; t: RoomType; ls?: Date; f?: boolean; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 52113dc9a07b8..36a84c9cb43b5 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -4340,6 +4340,7 @@ "React_when_read_only_changed_successfully": "Allow reacting when read only changed successfully", "React_with__reaction__": "Reacted with {{reaction}}", "Reacted_with": "Reacted with", + "Reaction_Notification": "{{name}} reacted with {{reaction}} to your message: {{message}}", "Reactions": "Reactions", "Read_Receipts": "Read receipts", "Read_by": "Read by", @@ -4360,6 +4361,7 @@ "Receive_Group_Mentions": "Receive @all and @here mentions", "Receive_Login_Detection_Emails": "Receive login detection emails", "Receive_Login_Detection_Emails_Description": "Receive an email each time a new login is detected on your account.", + "Receive_Reaction_Notifications": "Receive Reaction Notifications", "Receive_alerts": "Receive alerts", "Receive_login_notifications": "Receive login notifications", "Recent": "Recent", @@ -6606,6 +6608,10 @@ "one": "{{count}} mention", "other": "{{count}} mentions" }, + "reactions_counter": { + "one": "{{count}} reaction", + "other": "{{count}} reactions" + }, "message": "message", "message-impersonate": "Impersonate Other Users", "message-impersonate_description": "Permission to impersonate other users using message alias", diff --git a/packages/model-typings/src/models/ISubscriptionsModel.ts b/packages/model-typings/src/models/ISubscriptionsModel.ts index 0610fbd5e73e6..37c57ec9c61f2 100644 --- a/packages/model-typings/src/models/ISubscriptionsModel.ts +++ b/packages/model-typings/src/models/ISubscriptionsModel.ts @@ -279,6 +279,7 @@ export interface ISubscriptionsModel extends IBaseModel { incUser?: number, incUnread?: number, ): Promise; + incReactionsForRoomIdAndUserIds(roomId: IRoom['_id'], userIds: IUser['_id'][], inc?: number): Promise; ignoreUser(data: { _id: string; ignoredUser: string; ignore?: boolean }): Promise; diff --git a/packages/models/src/models/Subscriptions.ts b/packages/models/src/models/Subscriptions.ts index 2ad0eab92b199..a354b05719dc6 100644 --- a/packages/models/src/models/Subscriptions.ts +++ b/packages/models/src/models/Subscriptions.ts @@ -216,6 +216,7 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri unread: 0, userMentions: 0, groupMentions: 0, + reactions: 0, ls: new Date(), }, }; @@ -1576,6 +1577,27 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.updateMany(query, update); } + incReactionsForRoomIdAndUserIds(roomId: IRoom['_id'], userIds: IUser['_id'][], inc = 1): Promise { + const query = { + 'rid': roomId, + 'u._id': { + $in: userIds, + }, + }; + + const update: UpdateFilter = { + $set: { + alert: true, + open: true, + }, + $inc: { + reactions: inc, + }, + }; + + return this.updateMany(query, update); + } + ignoreUser({ _id, ignoredUser: ignored, ignore = true }: { _id: string; ignoredUser: string; ignore?: boolean }): Promise { const query = { _id,