diff --git a/webapp/channels/src/actions/hooks.js b/webapp/channels/src/actions/hooks.js index 770a6d4841..52d2e6bc57 100644 --- a/webapp/channels/src/actions/hooks.js +++ b/webapp/channels/src/actions/hooks.js @@ -89,3 +89,31 @@ export function runMessageWillBeUpdatedHooks(newPost, oldPost) { return {data: post}; }; } + +export function runDesktopNotificationHooks(post, msgProps, channel, teamId, args) { + return async (dispatch, getState) => { + const hooks = getState().plugins.components.DesktopNotificationHooks; + if (!hooks || hooks.length === 0) { + return {args}; + } + + let nextArgs = args; + for (const hook of hooks) { + const result = await hook.hook(post, msgProps, channel, teamId, nextArgs); // eslint-disable-line no-await-in-loop + + if (result) { + if (result.error) { + return {error: result.error}; + } + + if (!result.args) { + return {error: 'returned empty args'}; + } + + nextArgs = result.args; + } + } + + return {args: nextArgs}; + }; +} diff --git a/webapp/channels/src/actions/hooks.test.js b/webapp/channels/src/actions/hooks.test.js index d82eb9cc8c..125f0349ce 100644 --- a/webapp/channels/src/actions/hooks.test.js +++ b/webapp/channels/src/actions/hooks.test.js @@ -1,9 +1,15 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {General} from 'mattermost-redux/constants'; import mockStore from 'tests/test_store'; -import {runMessageWillBePostedHooks, runMessageWillBeUpdatedHooks, runSlashCommandWillBePostedHooks} from './hooks'; +import { + runDesktopNotificationHooks, + runMessageWillBePostedHooks, + runMessageWillBeUpdatedHooks, + runSlashCommandWillBePostedHooks, +} from './hooks'; describe('runMessageWillBePostedHooks', () => { test('should do nothing when no hooks are registered', async () => { @@ -493,3 +499,267 @@ describe('runMessageWillBeUpdatedHooks', () => { expect(hook2).toHaveBeenCalledWith(newPost, oldPost); }); }); + +describe('runDesktopNotificationHooks', () => { + test('should do nothing when no hooks are registered', async () => { + const store = mockStore({ + plugins: { + components: {}, + }, + }); + const post = {id: 'postid01234567890123456789'}; + const teamId = 'teamid01234567890123456789'; + const msgProps = {mentions: ['userid1'], team_id: teamId}; + const channel = {type: General.DM_CHANNEL}; + const args = { + title: 'Notification title', + body: 'Notification body', + silent: false, + soundName: 'Bing', + url: 'http://localhost:8065/ad-1/channels/test', + notify: true, + }; + + const result = await store.dispatch(runDesktopNotificationHooks(post, msgProps, channel, teamId, args)); + + expect(result.args).toEqual(args); + }); + + test('should pass the args through every hook', async () => { + const hook1 = jest.fn((post, msgProps, channel, teamId, args) => ({args})); + const hook2 = jest.fn((post, msgProps, channel, teamId, args) => ({args})); + const hook3 = jest.fn((post, msgProps, channel, teamId, args) => ({args})); + + const store = mockStore({ + plugins: { + components: { + DesktopNotificationHooks: [ + {hook: hook1}, + {hook: hook2}, + {hook: hook3}, + ], + }, + }, + }); + const post = {id: 'postid01234567890123456789'}; + const teamId = 'teamid01234567890123456789'; + const msgProps = {mentions: ['userid1'], team_id: teamId}; + const channel = {type: General.DM_CHANNEL}; + const args = { + title: 'Notification title', + body: 'Notification body', + silent: false, + soundName: 'Bing', + url: 'http://localhost:8065/ad-1/channels/test', + notify: true, + }; + + const result = await store.dispatch(runDesktopNotificationHooks(post, msgProps, channel, teamId, args)); + + expect(result.args).toEqual(args); + expect(hook1).toHaveBeenCalledWith(post, msgProps, channel, teamId, args); + expect(hook2).toHaveBeenCalledWith(post, msgProps, channel, teamId, args); + expect(hook3).toHaveBeenCalledWith(post, msgProps, channel, teamId, args); + }); + + test('should return an error when a hook returns an error', async () => { + const hook1 = jest.fn((post, msgProps, channel, teamId, args) => ({args})); + const hook2 = jest.fn(() => ({error: 'an error occurred'})); + const hook3 = jest.fn((post, msgProps, channel, teamId, args) => ({args})); + + const store = mockStore({ + plugins: { + components: { + DesktopNotificationHooks: [ + {hook: hook1}, + {hook: hook2}, + {hook: hook3}, + ], + }, + }, + }); + const post = {id: 'postid01234567890123456789'}; + const teamId = 'teamid01234567890123456789'; + const msgProps = {mentions: ['userid1'], team_id: teamId}; + const channel = {type: General.DM_CHANNEL}; + const args = { + title: 'Notification title', + body: 'Notification body', + silent: false, + soundName: 'Bing', + url: 'http://localhost:8065/ad-1/channels/test', + notify: true, + }; + + const result = await store.dispatch(runDesktopNotificationHooks(post, msgProps, channel, teamId, args)); + + expect(result).toEqual({error: 'an error occurred'}); + expect(hook1).toHaveBeenCalledWith(post, msgProps, channel, teamId, args); + expect(hook2).toHaveBeenCalledWith(post, msgProps, channel, teamId, args); + expect(hook3).not.toHaveBeenCalled(); + }); + + test('should return an error when a hook returns an empty result', async () => { + const hook1 = jest.fn((post, msgProps, channel, teamId, args) => ({args})); + const hook2 = jest.fn(() => ({})); + const hook3 = jest.fn((post, msgProps, channel, teamId, args) => ({args})); + + const store = mockStore({ + plugins: { + components: { + DesktopNotificationHooks: [ + {hook: hook1}, + {hook: hook2}, + {hook: hook3}, + ], + }, + }, + }); + const post = {id: 'postid01234567890123456789'}; + const teamId = 'teamid01234567890123456789'; + const msgProps = {mentions: ['userid1'], team_id: teamId}; + const channel = {type: General.DM_CHANNEL}; + const args = { + title: 'Notification title', + body: 'Notification body', + silent: false, + soundName: 'Bing', + url: 'http://localhost:8065/ad-1/channels/test', + notify: true, + }; + + const result = await store.dispatch(runDesktopNotificationHooks(post, msgProps, channel, teamId, args)); + + expect(result).toEqual({error: 'returned empty args'}); + expect(hook1).toHaveBeenCalledWith(post, msgProps, channel, teamId, args); + expect(hook2).toHaveBeenCalledWith(post, msgProps, channel, teamId, args); + expect(hook3).not.toHaveBeenCalled(); + }); + + test('should continue to call next hooks when a hook returns null or undefined', async () => { + const hook1 = jest.fn(() => (null)); + const hook2 = jest.fn(() => (undefined)); + const hook3 = jest.fn((post, msgProps, channel, teamId, args) => ({args})); + + const store = mockStore({ + plugins: { + components: { + DesktopNotificationHooks: [ + {hook: hook1}, + {hook: hook2}, + {hook: hook3}, + ], + }, + }, + }); + const post = {id: 'postid01234567890123456789'}; + const teamId = 'teamid01234567890123456789'; + const msgProps = {mentions: ['userid1'], team_id: teamId}; + const channel = {type: General.DM_CHANNEL}; + const args = { + title: 'Notification title', + body: 'Notification body', + silent: false, + soundName: 'Bing', + url: 'http://localhost:8065/ad-1/channels/test', + notify: true, + }; + + const result = await store.dispatch(runDesktopNotificationHooks(post, msgProps, channel, teamId, args)); + + expect(result.args).toEqual(args); + expect(hook1).toHaveBeenCalledWith(post, msgProps, channel, teamId, args); + expect(hook2).toHaveBeenCalledWith(post, msgProps, channel, teamId, args); + expect(hook3).toHaveBeenCalledWith(post, msgProps, channel, teamId, args); + }); + + test('should pass the result of each hook to the next', async () => { + const hook1 = jest.fn((post, msgProps, channel, teamId, args) => ({args: {...args, title: args.title + 'a'}})); + const hook2 = jest.fn((post, msgProps, channel, teamId, args) => ({ + args: { + ...args, + title: args.title + 'b', + notify: false, + }, + })); + const hook3 = jest.fn((post, msgProps, channel, teamId, args) => ({ + args: { + ...args, + title: args.title + 'c', + notify: true, + }, + })); + + const store = mockStore({ + plugins: { + components: { + DesktopNotificationHooks: [ + {hook: hook1}, + {hook: hook2}, + {hook: hook3}, + ], + }, + }, + }); + const post = {id: 'postid01234567890123456789'}; + const teamId = 'teamid01234567890123456789'; + const msgProps = {mentions: ['userid1'], team_id: teamId}; + const channel = {type: General.DM_CHANNEL}; + const args = { + title: 'Notification title', + body: 'Notification body', + silent: false, + soundName: 'Bing', + url: 'http://localhost:8065/ad-1/channels/test', + notify: true, + }; + + const result = await store.dispatch(runDesktopNotificationHooks(post, msgProps, channel, teamId, args)); + + expect(result.args).toEqual({...args, title: 'Notification titleabc', notify: true}); + expect(hook1).toHaveBeenCalledWith(post, msgProps, channel, teamId, args); + expect(hook2).toHaveBeenCalledWith(post, msgProps, channel, teamId, {...args, title: 'Notification titlea'}); + expect(hook3).toHaveBeenCalledWith(post, msgProps, channel, teamId, {...args, title: 'Notification titleab', notify: false}); + }); + + test('should wait for async hooks', async () => { + jest.useFakeTimers(); + + const hook = jest.fn((post, msgProps, channel, teamId, args) => { + return new Promise((resolve) => { + setTimeout(() => { + resolve({args: {...args, title: args.title + ' async'}}); + }, 100); + + jest.runOnlyPendingTimers(); + }); + }); + + const store = mockStore({ + plugins: { + components: { + DesktopNotificationHooks: [ + {hook}, + ], + }, + }, + }); + const post = {id: 'postid01234567890123456789'}; + const teamId = 'teamid01234567890123456789'; + const msgProps = {mentions: ['userid1'], team_id: teamId}; + const channel = {type: General.DM_CHANNEL}; + const args = { + title: 'Notification title', + body: 'Notification body', + silent: false, + soundName: 'Bing', + url: 'http://localhost:8065/ad-1/channels/test', + notify: true, + }; + + const result = await store.dispatch(runDesktopNotificationHooks(post, msgProps, channel, teamId, args)); + + expect(result.args).toEqual({...args, title: 'Notification title async'}); + expect(hook).toHaveBeenCalledWith(post, msgProps, channel, teamId, args); + }); +}); diff --git a/webapp/channels/src/actions/notification_actions.jsx b/webapp/channels/src/actions/notification_actions.jsx index f0d03ce38b..81dba52c5f 100644 --- a/webapp/channels/src/actions/notification_actions.jsx +++ b/webapp/channels/src/actions/notification_actions.jsx @@ -5,7 +5,10 @@ import {logError} from 'mattermost-redux/actions/errors'; import {getProfilesByIds} from 'mattermost-redux/actions/users'; import {getCurrentChannel, getMyChannelMember, makeGetChannel} from 'mattermost-redux/selectors/entities/channels'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; -import {getTeammateNameDisplaySetting, isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences'; +import { + getTeammateNameDisplaySetting, + isCollapsedThreadsEnabled, +} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentUserId, getCurrentUser, getStatusForUserId, getUser} from 'mattermost-redux/selectors/entities/users'; import {isChannelMuted} from 'mattermost-redux/utils/channel_utils'; import {isSystemMessage, isUserAddedInChannel} from 'mattermost-redux/utils/post_utils'; @@ -22,6 +25,7 @@ import {isDesktopApp, isMobileApp, isWindowsApp} from 'utils/user_agent'; import * as Utils from 'utils/utils'; import {t} from 'utils/i18n'; import {stripMarkdown} from 'utils/markdown'; +import {runDesktopNotificationHooks} from './hooks'; const NOTIFY_TEXT_MAX_LENGTH = 50; @@ -190,17 +194,28 @@ export function sendDesktopNotification(post, msgProps) { } notify = notify || !state.views.browser.focused; - const soundName = getNotificationSoundFromChannelMemberAndUser(member, user); + let soundName = getNotificationSoundFromChannelMemberAndUser(member, user); + + const updatedState = getState(); + let url = getChannelURL(updatedState, channel, teamId); + + if (isCrtReply) { + url = getPermalinkURL(updatedState, teamId, post.id); + } + + // Allow plugins to change the notification, or re-enable a notification + const args = {title, body, silent: !sound, soundName, url, notify}; + const hookResult = await dispatch(runDesktopNotificationHooks(post, msgProps, channel, teamId, args)); + if (hookResult.error) { + dispatch(logError(hookResult.error)); + return; + } + + let silent = false; + ({title, body, silent, soundName, url, notify} = hookResult.args); if (notify) { - const updatedState = getState(); - let url = getChannelURL(updatedState, channel, teamId); - - if (isCrtReply) { - url = getPermalinkURL(updatedState, teamId, post.id); - } - - dispatch(notifyMe(title, body, channel, teamId, !sound, soundName, url)); + dispatch(notifyMe(title, body, channel, teamId, silent, soundName, url)); //Don't add extra sounds on native desktop clients if (sound && !isDesktopApp() && !isMobileApp()) { diff --git a/webapp/channels/src/actions/notification_actions.test.js b/webapp/channels/src/actions/notification_actions.test.js index c8b5a70599..873a8e6e10 100644 --- a/webapp/channels/src/actions/notification_actions.test.js +++ b/webapp/channels/src/actions/notification_actions.test.js @@ -153,6 +153,11 @@ describe('notification_actions', () => { isSidebarOpen: true, }, }, + plugins: { + components: { + DesktopNotificationHooks: [], + }, + }, }; }); diff --git a/webapp/channels/src/components/user_settings/notifications/index.ts b/webapp/channels/src/components/user_settings/notifications/index.ts index 6464a747a4..4cec45aae5 100644 --- a/webapp/channels/src/components/user_settings/notifications/index.ts +++ b/webapp/channels/src/components/user_settings/notifications/index.ts @@ -12,7 +12,7 @@ import {ActionFunc} from 'mattermost-redux/types/actions'; import {GlobalState} from 'types/store'; import UserSettingsNotifications, {Props} from './user_settings_notifications'; -import {isCallsEnabled, isCallsRingingEnabled} from 'selectors/calls'; +import {isCallsEnabled, isCallsRingingEnabledOnServer} from 'selectors/calls'; function mapStateToProps(state: GlobalState) { const config = getConfig(state); @@ -24,7 +24,7 @@ function mapStateToProps(state: GlobalState) { sendPushNotifications, enableAutoResponder, isCollapsedThreadsEnabled: isCollapsedThreadsEnabled(state), - isCallsRingingEnabled: isCallsEnabled(state, '0.17.0') && isCallsRingingEnabled(state), + isCallsRingingEnabled: isCallsEnabled(state, '0.17.0') && isCallsRingingEnabledOnServer(state), }; } diff --git a/webapp/channels/src/plugins/registry.ts b/webapp/channels/src/plugins/registry.ts index 22f003951d..8afd659a5d 100644 --- a/webapp/channels/src/plugins/registry.ts +++ b/webapp/channels/src/plugins/registry.ts @@ -1164,4 +1164,52 @@ export default class PluginRegistry { }, }); }); + + // Register a hook to intercept desktop notifications before they occur. + // Accepts a function to run before the desktop notification is triggered. + // The function has the following signature: + // (post: Post, msgProps: NewPostMessageProps, channel: Channel, + // teamId: string, args: DesktopNotificationArgs) => Promise<{ + // error?: string; + // args?: DesktopNotificationArgs; + // }>) + // + // DesktopNotificationArgs is the following type: + // export type DesktopNotificationArgs = { + // title: string; + // body: string; + // silent: boolean; + // soundName: string; + // url: string; + // notify: boolean; + // }; + // + // To stop a desktop notification and allow subsequent hooks to process the notification, return: + // {args: {...args, notify: false}} + // To enable a desktop notification and allow subsequent hooks to process the notification, return: + // {args: {...args, notify: true}} + // To stop a desktop notification and prevent subsequent hooks from processing the notification, return either: + // {error: 'log this error'}, or {} + // To allow subsequent hooks to process the notification, return: + // {args}, or null or undefined (thanks js) + // + // The args returned by the hook will be used as the args for the next hook, until all hooks are + // completed. The resulting args will be used as the arguments for the `notifyMe` function. + // + // Returns a unique identifier. + registerDesktopNotificationHook = reArg(['hook'], ({hook}) => { + const id = generateId(); + + store.dispatch({ + type: ActionTypes.RECEIVED_PLUGIN_COMPONENT, + name: 'DesktopNotificationHooks', + data: { + id, + pluginId: this.id, + hook, + }, + }); + + return id; + }); } diff --git a/webapp/channels/src/reducers/plugins/index.ts b/webapp/channels/src/reducers/plugins/index.ts index 87f19c5ed7..20f0ba78cb 100644 --- a/webapp/channels/src/reducers/plugins/index.ts +++ b/webapp/channels/src/reducers/plugins/index.ts @@ -187,6 +187,7 @@ const initialComponents: PluginsState['components'] = { FilesWillUploadHook: [], NeedsTeamComponent: [], CreateBoardFromTemplate: [], + DesktopNotificationHooks: [], }; function components(state: PluginsState['components'] = initialComponents, action: GenericAction) { diff --git a/webapp/channels/src/selectors/calls.ts b/webapp/channels/src/selectors/calls.ts index f7b18d1bf1..e2687968ad 100644 --- a/webapp/channels/src/selectors/calls.ts +++ b/webapp/channels/src/selectors/calls.ts @@ -10,7 +10,8 @@ export function isCallsEnabled(state: GlobalState, minVersion = '0.4.2') { semver.gte(state.plugins.plugins[suitePluginIds.calls].version || '0.0.0', minVersion)); } -export function isCallsRingingEnabled(state: GlobalState) { +// isCallsRingingEnabledOnServer is the flag for the ringing/notification feature in calls +export function isCallsRingingEnabledOnServer(state: GlobalState) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore return Boolean(state[`plugins-${suitePluginIds.calls}`]?.callsConfig?.EnableRinging); diff --git a/webapp/channels/src/types/store/plugins.ts b/webapp/channels/src/types/store/plugins.ts index cffffb5fcd..63b82121b2 100644 --- a/webapp/channels/src/types/store/plugins.ts +++ b/webapp/channels/src/types/store/plugins.ts @@ -15,6 +15,8 @@ import {IconGlyphTypes} from '@mattermost/compass-icons/IconGlyphs'; import {WebSocketClient} from '@mattermost/client'; import {GlobalState} from 'types/store'; +import {Channel} from '@mattermost/types/channels'; +import {NewPostMessageProps} from 'actions/new_post'; export type PluginSiteStatsHandler = () => Promise>; @@ -37,6 +39,7 @@ export type PluginsState = { FilesWillUploadHook: PluginComponent[]; NeedsTeamComponent: NeedsTeamComponent[]; CreateBoardFromTemplate: PluginComponent[]; + DesktopNotificationHooks: DesktopNotificationHook[]; }; postTypes: { @@ -65,7 +68,7 @@ export type PluginsState = { export type Menu = { id: string; parentMenuId?: string; - text?: React.ReactElement|string; + text?: React.ReactElement | string; selectedValueText?: string; subMenu?: Menu[]; filter?: (id?: string) => boolean; @@ -113,7 +116,7 @@ export type FilePreviewComponent = { id: string; pluginId: string; override: (fileInfo: FileInfo, post?: Post) => boolean; - component: React.ComponentType<{fileInfo: FileInfo; post?: Post; onModalDismissed: () => void}>; + component: React.ComponentType<{ fileInfo: FileInfo; post?: Post; onModalDismissed: () => void }>; } export type FileDropdownPluginComponent = { @@ -220,3 +223,19 @@ export type ProductComponent = { */ wrapped: boolean; }; + +export type DesktopNotificationArgs = { + title: string; + body: string; + silent: boolean; + soundName: string; + url: string; + notify: boolean; +}; + +export type DesktopNotificationHook = PluginComponent & { + hook: (post: Post, msgProps: NewPostMessageProps, channel: Channel, teamId: string, args: DesktopNotificationArgs) => Promise<{ + error?: string; + args?: DesktopNotificationArgs; + }>; +} diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index e237795b34..950397c29b 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -803,6 +803,8 @@ export const PostTypes = { REMOVE_LINK_PREVIEW: 'remove_link_preview', ME: 'me', REMINDER: 'reminder', + CUSTOM_CALLS: 'custom_calls', + CUSTOM_CALLS_RECORDING: 'custom_calls_recording', }; export const StatTypes = keyMirror({