MM-53340 - Create registerDesktopNotificationHook for plugins, e.g. Calls (#23884)

* calls will notify in GM/DM channels for call started posts

* fix types

* add registerDesktopNotificationHook

* remove callsWillNotify

* cleanup unused types

* use an args object to shorten params

* add CUSTOM_CALLS_RECORDING to constants
Этот коммит содержится в:
Christopher Poile
2023-07-11 22:46:59 -04:00
коммит произвёл GitHub
родитель f45f774ece
Коммит 46a659e06d
10 изменённых файлов: 405 добавлений и 16 удалений

Просмотреть файл

@@ -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};
};
}

Просмотреть файл

@@ -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);
});
});

Просмотреть файл

@@ -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()) {

Просмотреть файл

@@ -153,6 +153,11 @@ describe('notification_actions', () => {
isSidebarOpen: true,
},
},
plugins: {
components: {
DesktopNotificationHooks: [],
},
},
};
});

Просмотреть файл

@@ -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),
};
}

Просмотреть файл

@@ -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;
});
}

Просмотреть файл

@@ -187,6 +187,7 @@ const initialComponents: PluginsState['components'] = {
FilesWillUploadHook: [],
NeedsTeamComponent: [],
CreateBoardFromTemplate: [],
DesktopNotificationHooks: [],
};
function components(state: PluginsState['components'] = initialComponents, action: GenericAction) {

Просмотреть файл

@@ -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);

Просмотреть файл

@@ -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<Record<string, PluginAnalyticsRow>>;
@@ -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;
}>;
}

Просмотреть файл

@@ -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({