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
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
f45f774ece
Коммит
46a659e06d
@@ -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({
|
||||
|
||||
Ссылка в новой задаче
Block a user