Migrate hooks and notification actions to typescript (#28858)

* Migrate hooks and notification actions to typescript

* Remove missing files

* Fix hook types

* Fix tests
Этот коммит содержится в:
Daniel Espino García
2024-10-28 17:07:11 +01:00
коммит произвёл GitHub
родитель f9bd093649
Коммит dd519629be
20 изменённых файлов: 586 добавлений и 430 удалений

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

@@ -303,7 +303,7 @@ export async function getTeamRedirectChannelIfIsAccesible(user: UserProfile, tea
channel = dmList.find((directChannel) => directChannel.name === channelName);
}
let channelMember: ChannelMembership | null | undefined;
let channelMember: ChannelMembership | undefined;
if (channel) {
channelMember = getMyChannelMember(state, channel.id);
}

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

@@ -523,7 +523,7 @@ describe('runDesktopNotificationHooks', () => {
const result = await store.dispatch(runDesktopNotificationHooks(post, msgProps, channel, teamId, args));
expect(result.args).toEqual(args);
expect(result.data).toEqual(args);
});
test('should pass the args through every hook', async () => {
@@ -557,7 +557,7 @@ describe('runDesktopNotificationHooks', () => {
const result = await store.dispatch(runDesktopNotificationHooks(post, msgProps, channel, teamId, args));
expect(result.args).toEqual(args);
expect(result.data).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);
@@ -668,7 +668,7 @@ describe('runDesktopNotificationHooks', () => {
const result = await store.dispatch(runDesktopNotificationHooks(post, msgProps, channel, teamId, args));
expect(result.args).toEqual(args);
expect(result.data).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);
@@ -717,7 +717,7 @@ describe('runDesktopNotificationHooks', () => {
const result = await store.dispatch(runDesktopNotificationHooks(post, msgProps, channel, teamId, args));
expect(result.args).toEqual({...args, title: 'Notification titleabc', notify: true});
expect(result.data).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});
@@ -760,7 +760,7 @@ describe('runDesktopNotificationHooks', () => {
const result = await store.dispatch(runDesktopNotificationHooks(post, msgProps, channel, teamId, args));
expect(result.args).toEqual({...args, title: 'Notification title async'});
expect(result.data).toEqual({...args, title: 'Notification title async'});
expect(hook).toHaveBeenCalledWith(post, msgProps, channel, teamId, args);
});
});

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

@@ -1,11 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Channel} from '@mattermost/types/channels';
import type {CommandArgs} from '@mattermost/types/integrations';
import type {Post} from '@mattermost/types/posts';
import type {ActionFuncAsync} from 'mattermost-redux/types/actions';
import type {GlobalState} from 'types/store';
import type {DesktopNotificationArgs} from 'types/store/plugins';
import type {NewPostMessageProps} from './new_post';
/**
* @param {Post} originalPost
* @returns {ActionFuncAsync<Post>}
*/
export function runMessageWillBePostedHooks(originalPost) {
export function runMessageWillBePostedHooks(originalPost: Post): ActionFuncAsync<Post, GlobalState> {
return async (dispatch, getState) => {
const hooks = getState().plugins.components.MessageWillBePosted;
if (!hooks || hooks.length === 0) {
@@ -15,10 +26,10 @@ export function runMessageWillBePostedHooks(originalPost) {
let post = originalPost;
for (const hook of hooks) {
const result = await hook.hook(post); // eslint-disable-line no-await-in-loop
const result = await hook.hook?.(post); // eslint-disable-line no-await-in-loop
if (result) {
if (result.error) {
if ('error' in result) {
return {
error: result.error,
};
@@ -32,7 +43,7 @@ export function runMessageWillBePostedHooks(originalPost) {
};
}
export function runSlashCommandWillBePostedHooks(originalMessage, originalArgs) {
export function runSlashCommandWillBePostedHooks(originalMessage: string, originalArgs: CommandArgs): ActionFuncAsync<{message: string; args: CommandArgs}, GlobalState> {
return async (dispatch, getState) => {
const hooks = getState().plugins.components.SlashCommandWillBePosted;
if (!hooks || hooks.length === 0) {
@@ -43,10 +54,10 @@ export function runSlashCommandWillBePostedHooks(originalMessage, originalArgs)
let args = originalArgs;
for (const hook of hooks) {
const result = await hook.hook(message, args); // eslint-disable-line no-await-in-loop
const result = await hook.hook?.(message, args); // eslint-disable-line no-await-in-loop
if (result) {
if (result.error) {
if ('error' in result) {
return {
error: result.error,
};
@@ -67,7 +78,7 @@ export function runSlashCommandWillBePostedHooks(originalMessage, originalArgs)
};
}
export function runMessageWillBeUpdatedHooks(newPost, oldPost) {
export function runMessageWillBeUpdatedHooks(newPost: Partial<Post>, oldPost: Post): ActionFuncAsync<Partial<Post>, GlobalState> {
return async (dispatch, getState) => {
const hooks = getState().plugins.components.MessageWillBeUpdated;
if (!hooks || hooks.length === 0) {
@@ -77,10 +88,10 @@ export function runMessageWillBeUpdatedHooks(newPost, oldPost) {
let post = newPost;
for (const hook of hooks) {
const result = await hook.hook(post, oldPost); // eslint-disable-line no-await-in-loop
const result = await hook.hook?.(post, oldPost); // eslint-disable-line no-await-in-loop
if (result) {
if (result.error) {
if ('error' in result) {
return {
error: result.error,
};
@@ -94,11 +105,11 @@ export function runMessageWillBeUpdatedHooks(newPost, oldPost) {
};
}
export function runDesktopNotificationHooks(post, msgProps, channel, teamId, args) {
export function runDesktopNotificationHooks(post: Post, msgProps: NewPostMessageProps, channel: Channel, teamId: string, args: DesktopNotificationArgs): ActionFuncAsync<DesktopNotificationArgs, GlobalState> {
return async (dispatch, getState) => {
const hooks = getState().plugins.components.DesktopNotificationHooks;
if (!hooks || hooks.length === 0) {
return {args};
return {data: args};
}
let nextArgs = args;
@@ -118,6 +129,6 @@ export function runDesktopNotificationHooks(post, msgProps, channel, teamId, arg
}
}
return {args: nextArgs};
return {data: nextArgs};
};
}

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

@@ -75,6 +75,9 @@ describe('actions/new_post', () => {
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {},
},
},
general: {
license: {IsLicensed: 'false'},
@@ -96,7 +99,17 @@ describe('actions/new_post', () => {
test('completePostReceive', async () => {
const testStore = mockStore(initialState);
const newPost = {id: 'new_post_id', channel_id: 'current_channel_id', message: 'new message', type: Constants.PostTypes.ADD_TO_CHANNEL, user_id: 'some_user_id', create_at: POST_CREATED_TIME, props: {addedUserId: 'other_user_id'}} as unknown as Post;
const websocketProps = {team_id: 'team_id', mentions: ['current_user_id'], should_ack: false};
const websocketProps: NewPostActions.NewPostMessageProps = {
team_id: 'team_id',
mentions: JSON.stringify(['current_user_id']),
should_ack: false,
channel_display_name: '',
channel_name: '',
channel_type: 'P',
post: JSON.stringify(newPost),
sender_name: '',
set_online: false,
};
await testStore.dispatch(NewPostActions.completePostReceive(newPost, websocketProps));
expect(testStore.getActions()).toEqual([

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

@@ -4,6 +4,7 @@
import type {AnyAction} from 'redux';
import {batchActions} from 'redux-batched-actions';
import type {ChannelType} from '@mattermost/types/channels';
import type {Post} from '@mattermost/types/posts';
import {
@@ -24,7 +25,7 @@ import {
shouldIgnorePost,
} from 'mattermost-redux/utils/post_utils';
import {sendDesktopNotification} from 'actions/notification_actions.jsx';
import {sendDesktopNotification} from 'actions/notification_actions';
import {updateThreadLastOpened} from 'actions/views/threads';
import {isThreadOpen, makeGetThreadLastViewedAt} from 'selectors/views/threads';
@@ -34,9 +35,18 @@ import {ActionTypes} from 'utils/constants';
import type {GlobalState} from 'types/store';
export type NewPostMessageProps = {
mentions: string[];
channel_type: ChannelType;
channel_display_name: string;
channel_name: string;
sender_name: string;
set_online: boolean;
mentions?: string;
followers?: string;
team_id: string;
should_ack: boolean;
otherFile?: 'true';
image?: 'true';
post: string;
}
export function completePostReceive(post: Post, websocketMessageProps: NewPostMessageProps, fetchedChannelMember?: boolean): ActionFuncAsync<boolean, GlobalState> {
@@ -85,7 +95,7 @@ export function completePostReceive(post: Post, websocketMessageProps: NewPostMe
dispatch(setThreadRead(post));
}
const {status, reason, data} = await dispatch(sendDesktopNotification(post, websocketMessageProps));
const {status, reason, data} = (await dispatch(sendDesktopNotification(post, websocketMessageProps))).data!;
// Only ACK for posts that require it
if (websocketMessageProps.should_ack) {
@@ -137,7 +147,7 @@ export function setChannelReadAndViewed(dispatch: DispatchFunc, getState: GetSta
return actionsToMarkChannelAsRead(getState, post.channel_id);
}
return actionsToMarkChannelAsUnread(getState, websocketMessageProps.team_id, post.channel_id, websocketMessageProps.mentions, fetchedChannelMember, post.root_id === '', post?.metadata?.priority?.priority);
return actionsToMarkChannelAsUnread(getState, websocketMessageProps.team_id, post.channel_id, websocketMessageProps.mentions || '', fetchedChannelMember, post.root_id === '', post?.metadata?.priority?.priority);
}
export function setThreadRead(post: Post): ActionFunc<boolean, GlobalState> {

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

@@ -1,382 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {logError} from 'mattermost-redux/actions/errors';
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 {getAllUserMentionKeys} from 'mattermost-redux/selectors/entities/search';
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';
import {displayUsername} from 'mattermost-redux/utils/user_utils';
import {getChannelURL, getPermalinkURL} from 'selectors/urls';
import {isThreadOpen} from 'selectors/views/threads';
import {getHistory} from 'utils/browser_history';
import Constants, {NotificationLevels, UserStatuses, IgnoreChannelMentions, DesktopSound} from 'utils/constants';
import DesktopApp from 'utils/desktop_api';
import {stripMarkdown, formatWithRenderer} from 'utils/markdown';
import MentionableRenderer from 'utils/markdown/mentionable_renderer';
import {DesktopNotificationSounds, ding} from 'utils/notification_sounds';
import {showNotification} from 'utils/notifications';
import {cjkrPattern, escapeRegex} from 'utils/text_formatting';
import {isDesktopApp, isMobileApp} from 'utils/user_agent';
import * as Utils from 'utils/utils';
import {runDesktopNotificationHooks} from './hooks';
/**
* This function is used to determine if the desktop sound is enabled.
* It checks if desktop sound is defined in the channel member and if not, it checks if it's defined in the user preferences.
*/
export function isDesktopSoundEnabled(channelMember, user) {
const soundInChannelMemberNotifyProps = channelMember?.notify_props?.desktop_sound;
const soundInUserNotifyProps = user?.notify_props?.desktop_sound;
if (soundInChannelMemberNotifyProps === DesktopSound.ON) {
return true;
}
if (soundInChannelMemberNotifyProps === DesktopSound.OFF) {
return false;
}
if (soundInChannelMemberNotifyProps === DesktopSound.DEFAULT) {
return soundInUserNotifyProps ? soundInUserNotifyProps === 'true' : true;
}
if (soundInUserNotifyProps) {
return soundInUserNotifyProps === 'true';
}
return true;
}
/**
* This function returns the desktop notification sound from the channel member and user.
* It checks if desktop notification sound is defined in the channel member and if not, it checks if it's defined in the user preferences.
* If neither is defined, it returns the default sound 'BING'.
*/
export function getDesktopNotificationSound(channelMember, user) {
const notificationSoundInChannelMember = channelMember?.notify_props?.desktop_notification_sound;
const notificationSoundInUser = user?.notify_props?.desktop_notification_sound;
if (notificationSoundInChannelMember && notificationSoundInChannelMember !== DesktopNotificationSounds.DEFAULT) {
return notificationSoundInChannelMember;
}
if (notificationSoundInUser && notificationSoundInUser !== DesktopNotificationSounds.DEFAULT) {
return notificationSoundInUser;
}
return DesktopNotificationSounds.BING;
}
/**
* @returns {import('mattermost-redux/types/actions').ThunkActionFunc<Promise<import('utils/notifications').NotificationResult>, GlobalState>}
*/
export function sendDesktopNotification(post, msgProps) {
return async (dispatch, getState) => {
const state = getState();
const currentUserId = getCurrentUserId(state);
if ((currentUserId === post.user_id && post.props.from_webhook !== 'true')) {
return {status: 'not_sent', reason: 'own_post'};
}
if (isSystemMessage(post) && !isUserAddedInChannel(post, currentUserId)) {
return {status: 'not_sent', reason: 'system_message'};
}
let mentions = [];
if (msgProps.mentions) {
mentions = JSON.parse(msgProps.mentions);
}
let followers = [];
if (msgProps.followers) {
followers = JSON.parse(msgProps.followers);
mentions = [...new Set([...followers, ...mentions])];
}
const teamId = msgProps.team_id;
let channel = makeGetChannel()(state, post.channel_id);
const user = getCurrentUser(state);
const userStatus = getStatusForUserId(state, user.id);
const member = getMyChannelMember(state, post.channel_id);
const isCrtReply = isCollapsedThreadsEnabled(state) && post.root_id !== '';
if (!member) {
return {status: 'error', reason: 'no_member'};
}
if (isChannelMuted(member)) {
return {status: 'not_sent', reason: 'channel_muted'};
}
if (userStatus === UserStatuses.DND || userStatus === UserStatuses.OUT_OF_OFFICE) {
return {status: 'not_sent', reason: 'user_status', data: userStatus};
}
const channelNotifyProp = member?.notify_props?.desktop || NotificationLevels.DEFAULT;
let notifyLevel = channelNotifyProp;
if (notifyLevel === NotificationLevels.DEFAULT) {
notifyLevel = user?.notify_props?.desktop || NotificationLevels.ALL;
}
if (channel?.type === 'G' && channelNotifyProp === NotificationLevels.DEFAULT && user?.notify_props?.desktop === NotificationLevels.MENTION) {
notifyLevel = NotificationLevels.ALL;
}
if (notifyLevel === NotificationLevels.NONE) {
return {status: 'not_sent', reason: 'notify_level_none'};
} else if (channel?.type === 'G' && notifyLevel === NotificationLevels.MENTION) {
// Compose the whole text in the message, including interactive messages.
let text = post.message;
// We do this on a try catch block to avoid errors from malformed props
try {
if (post.props && post.props.attachments) {
const attachments = post.props.attachments;
function appendText(toAppend) {
if (toAppend) {
text += `\n${toAppend}`;
}
}
for (const attachment of attachments) {
appendText(attachment.pretext);
appendText(attachment.title);
appendText(attachment.text);
appendText(attachment.footer);
if (attachment.fields) {
for (const field of attachment.fields) {
appendText(field.title);
appendText(field.value);
}
}
}
}
} catch (e) {
// eslint-disable-next-line no-console
console.log('Could not process the whole attachment for mentions', e);
}
const allMentions = getAllUserMentionKeys(state);
const ignoreChannelMentionProp = member?.notify_props?.ignore_channel_mentions || IgnoreChannelMentions.DEFAULT;
let ignoreChannelMention = ignoreChannelMentionProp === IgnoreChannelMentions.ON;
if (ignoreChannelMentionProp === IgnoreChannelMentions.DEFAULT) {
ignoreChannelMention = user?.notify_props?.channel === 'false';
}
const mentionableText = formatWithRenderer(text, new MentionableRenderer());
let isExplicitlyMentioned = false;
for (const mention of allMentions) {
if (!mention || !mention.key) {
continue;
}
if (ignoreChannelMention && ['@all', '@here', '@channel'].includes(mention.key)) {
continue;
}
let flags = 'g';
if (!mention.caseSensitive) {
flags += 'i';
}
let pattern;
if (cjkrPattern.test(mention.key)) {
// In the case of CJK mention key, even if there's no delimiters (such as spaces) at both ends of a word, it is recognized as a mention key
pattern = new RegExp(`()(${escapeRegex(mention.key)})()`, flags);
} else {
pattern = new RegExp(
`(^|\\W)(${escapeRegex(mention.key)})(\\b|_+\\b)`,
flags,
);
}
if (pattern.test(mentionableText)) {
isExplicitlyMentioned = true;
break;
}
}
if (!isExplicitlyMentioned) {
return {status: 'not_sent', reason: 'not_explicitly_mentioned', data: mentionableText};
}
} else if (notifyLevel === NotificationLevels.MENTION && mentions.indexOf(user.id) === -1 && msgProps.channel_type !== Constants.DM_CHANNEL) {
return {status: 'not_sent', reason: 'not_mentioned'};
} else if (isCrtReply && notifyLevel === NotificationLevels.ALL && followers.indexOf(currentUserId) === -1) {
// if user is not following the thread don't notify
return {status: 'not_sent', reason: 'not_following_thread'};
}
const config = getConfig(state);
const userFromPost = getUser(state, post.user_id);
let username = '';
if (post.props.override_username && config.EnablePostUsernameOverride === 'true') {
username = post.props.override_username;
} else if (userFromPost) {
username = displayUsername(userFromPost, getTeammateNameDisplaySetting(state), false);
} else if (msgProps.sender_name) {
username = msgProps.sender_name;
} else {
username = Utils.localizeMessage({id: 'channel_loader.someone', defaultMessage: 'Someone'});
}
let title = Utils.localizeMessage({id: 'channel_loader.posted', defaultMessage: 'Posted'});
if (!channel) {
title = msgProps.channel_display_name;
channel = {
name: msgProps.channel_name,
type: msgProps.channel_type,
};
} else if (channel.type === Constants.DM_CHANNEL) {
title = Utils.localizeMessage({id: 'notification.dm', defaultMessage: 'Direct Message'});
} else {
title = channel.display_name;
}
if (title === '') {
if (msgProps.channel_type === Constants.DM_CHANNEL) {
title = Utils.localizeMessage({id: 'notification.dm', defaultMessage: 'Direct Message'});
} else {
title = msgProps.channel_display_name;
}
}
if (isCrtReply) {
title = Utils.localizeAndFormatMessage({id: 'notification.crt', defaultMessage: 'Reply in {title}'}, {title});
}
let notifyText = post.message;
const msgPropsPost = JSON.parse(msgProps.post);
const attachments = msgPropsPost && msgPropsPost.props && msgPropsPost.props.attachments ? msgPropsPost.props.attachments : [];
let image = false;
attachments.forEach((attachment) => {
if (notifyText.length === 0) {
notifyText = attachment.fallback ||
attachment.pretext ||
attachment.text;
}
image |= attachment.image_url.length > 0;
});
const strippedMarkdownNotifyText = stripMarkdown(notifyText);
let body = `@${username}`;
if (strippedMarkdownNotifyText.length === 0) {
if (msgProps.image) {
body += Utils.localizeMessage({id: 'channel_loader.uploadedImage', defaultMessage: ' uploaded an image'});
} else if (msgProps.otherFile) {
body += Utils.localizeMessage({id: 'channel_loader.uploadedFile', defaultMessage: ' uploaded a file'});
} else if (image) {
body += Utils.localizeMessage({id: 'channel_loader.postedImage', defaultMessage: ' posted an image'});
} else {
body += Utils.localizeMessage({id: 'channel_loader.something', defaultMessage: ' did something new'});
}
} else {
body += `: ${strippedMarkdownNotifyText}`;
}
//Play a sound if explicitly set in settings
const desktopSoundEnabled = isDesktopSoundEnabled(member, user);
// Notify if you're not looking in the right channel or when
// the window itself is not active
const activeChannel = getCurrentChannel(state);
const channelId = channel ? channel.id : null;
let notify = false;
let notifyResult = {status: 'not_sent', reason: 'unknown'};
if (state.views.browser.focused) {
notifyResult = {status: 'not_sent', reason: 'window_is_focused'};
if (isCrtReply) {
notify = !isThreadOpen(state, post.root_id);
if (!notify) {
notifyResult = {status: 'not_sent', reason: 'thread_is_open', data: post.root_id};
}
} else {
notify = activeChannel && activeChannel.id !== channelId;
if (!notify) {
notifyResult = {status: 'not_sent', reason: 'channel_is_open', data: activeChannel?.id};
}
}
} else {
notify = true;
}
let soundName = getDesktopNotificationSound(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: !desktopSoundEnabled, soundName, url, notify};
const hookResult = await dispatch(runDesktopNotificationHooks(post, msgProps, channel, teamId, args));
if (hookResult.error) {
dispatch(logError(hookResult.error));
return {status: 'error', reason: 'desktop_notification_hook', data: String(hookResult.error)};
}
let silent = false;
({title, body, silent, soundName, url, notify} = hookResult.args);
if (notify) {
const result = dispatch(notifyMe(title, body, channel, teamId, silent, soundName, url));
//Don't add extra sounds on native desktop clients
if (desktopSoundEnabled && !isDesktopApp() && !isMobileApp()) {
ding(soundName);
}
return result;
}
if (args.notify && !notify) {
notifyResult = {status: 'not_sent', reason: 'desktop_notification_hook', data: String(hookResult)};
}
return notifyResult;
};
}
/**
* @returns {import('mattermost-redux/types/actions').ThunkActionFunc<Promise<import('utils/notifications').NotificationResult>, GlobalState>}
*/
export const notifyMe = (title, body, channel, teamId, silent, soundName, url) => async (dispatch) => {
// handle notifications in desktop app
if (isDesktopApp()) {
return DesktopApp.dispatchNotification(title, body, channel.id, teamId, silent, soundName, url);
}
try {
return await dispatch(showNotification({
title,
body,
requireInteraction: false,
silent,
onClick: () => {
window.focus();
getHistory().push(url);
},
}));
} catch (error) {
dispatch(logError(error));
return {status: 'error', reason: 'notification_api', data: String(error)};
}
};

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

@@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {MarkUnread} from 'mattermost-redux/constants/channels';
import testConfigureStore from 'tests/test_store';
import {getHistory} from 'utils/browser_history';
import Constants, {NotificationLevels, UserStatuses} from 'utils/constants';
@@ -20,7 +22,7 @@ describe('notification_actions', () => {
let userSettings;
beforeEach(() => {
spy = jest.spyOn(utils, 'showNotification');
spy = jest.spyOn(utils, 'showNotification').mockReturnValue(async () => ({status: 'success'}));
NotificationSounds.ding = jest.fn();
crt = {
@@ -111,6 +113,7 @@ describe('notification_actions', () => {
},
muted_channel_id: {
id: 'muted_channel_id',
display_name: 'Muted Channel',
team_id: 'team_id',
},
another_channel_id: {
@@ -131,6 +134,13 @@ describe('notification_actions', () => {
id: 'gm_channel',
notify_props: channelSettings,
},
muted_channel_id: {
id: 'muted_channel_id',
team_id: 'team_id',
notify_props: {
mark_unread: MarkUnread.MENTION,
},
},
},
membersInChannel: {
channel_id: {

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

@@ -0,0 +1,430 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Channel, ChannelMembership} from '@mattermost/types/channels';
import type {ServerError} from '@mattermost/types/errors';
import type {MessageAttachment} from '@mattermost/types/message_attachments';
import type {Post} from '@mattermost/types/posts';
import type {UserProfile} from '@mattermost/types/users';
import {logError} from 'mattermost-redux/actions/errors';
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 {getAllUserMentionKeys} from 'mattermost-redux/selectors/entities/search';
import {getCurrentUserId, getCurrentUser, getStatusForUserId, getUser} from 'mattermost-redux/selectors/entities/users';
import type {ActionFuncAsync} from 'mattermost-redux/types/actions';
import {isChannelMuted} from 'mattermost-redux/utils/channel_utils';
import {isSystemMessage, isUserAddedInChannel} from 'mattermost-redux/utils/post_utils';
import {displayUsername} from 'mattermost-redux/utils/user_utils';
import {getChannelURL, getPermalinkURL} from 'selectors/urls';
import {isThreadOpen} from 'selectors/views/threads';
import {getHistory} from 'utils/browser_history';
import Constants, {NotificationLevels, UserStatuses, IgnoreChannelMentions, DesktopSound} from 'utils/constants';
import DesktopApp from 'utils/desktop_api';
import {stripMarkdown, formatWithRenderer} from 'utils/markdown';
import MentionableRenderer from 'utils/markdown/mentionable_renderer';
import {DesktopNotificationSounds, ding} from 'utils/notification_sounds';
import {showNotification} from 'utils/notifications';
import {cjkrPattern, escapeRegex} from 'utils/text_formatting';
import {isDesktopApp, isMobileApp} from 'utils/user_agent';
import * as Utils from 'utils/utils';
import type {GlobalState} from 'types/store';
import {runDesktopNotificationHooks} from './hooks';
import type {NewPostMessageProps} from './new_post';
type NotificationResult = {
status: string;
reason?: string;
data?: string;
}
type NotificationHooksArgs = {
title: string;
body: string;
silent: boolean;
soundName: string;
url: string;
notify: boolean;
}
/**
* This function is used to determine if the desktop sound is enabled.
* It checks if desktop sound is defined in the channel member and if not, it checks if it's defined in the user preferences.
*/
export function isDesktopSoundEnabled(channelMember: ChannelMembership | undefined, user: UserProfile | undefined) {
const soundInChannelMemberNotifyProps = channelMember?.notify_props?.desktop_sound;
const soundInUserNotifyProps = user?.notify_props?.desktop_sound;
if (soundInChannelMemberNotifyProps === DesktopSound.ON) {
return true;
}
if (soundInChannelMemberNotifyProps === DesktopSound.OFF) {
return false;
}
if (soundInChannelMemberNotifyProps === DesktopSound.DEFAULT) {
return soundInUserNotifyProps ? soundInUserNotifyProps === 'true' : true;
}
if (soundInUserNotifyProps) {
return soundInUserNotifyProps === 'true';
}
return true;
}
/**
* This function returns the desktop notification sound from the channel member and user.
* It checks if desktop notification sound is defined in the channel member and if not, it checks if it's defined in the user preferences.
* If neither is defined, it returns the default sound 'BING'.
*/
export function getDesktopNotificationSound(channelMember: ChannelMembership | undefined, user: UserProfile | undefined) {
const notificationSoundInChannelMember = channelMember?.notify_props?.desktop_notification_sound;
const notificationSoundInUser = user?.notify_props?.desktop_notification_sound;
if (notificationSoundInChannelMember && notificationSoundInChannelMember !== DesktopNotificationSounds.DEFAULT) {
return notificationSoundInChannelMember;
}
if (notificationSoundInUser && notificationSoundInUser !== DesktopNotificationSounds.DEFAULT) {
return notificationSoundInUser;
}
return DesktopNotificationSounds.BING;
}
export function sendDesktopNotification(post: Post, msgProps: NewPostMessageProps): ActionFuncAsync<NotificationResult, GlobalState> {
return async (dispatch, getState) => {
const state = getState();
const teamId = msgProps.team_id;
const channel = makeGetChannel()(state, post.channel_id) || {
id: post.channel_id,
name: msgProps.channel_name,
display_name: msgProps.channel_display_name,
type: msgProps.channel_type,
};
const user = getCurrentUser(state);
const member = getMyChannelMember(state, post.channel_id);
const isCrtReply = isCollapsedThreadsEnabled(state) && post.root_id !== '';
const skipNotificationReason = shouldSkipNotification(
state,
post,
msgProps,
user,
channel,
member,
isCrtReply,
);
if (skipNotificationReason) {
return {data: skipNotificationReason};
}
const title = getNotificationTitle(channel, msgProps, isCrtReply);
const body = getNotificationBody(state, post, msgProps);
//Play a sound if explicitly set in settings
const desktopSoundEnabled = isDesktopSoundEnabled(member, user);
const soundName = getDesktopNotificationSound(member, user);
const updatedState = getState();
const url = isCrtReply ? getPermalinkURL(updatedState, teamId, post.id) : getChannelURL(updatedState, channel, teamId);
// Allow plugins to change the notification, or re-enable a notification
const args: NotificationHooksArgs = {title, body, silent: !desktopSoundEnabled, soundName, url, notify: true};
// TODO verify the type of the desktop hook.
// The channel may not be complete at this moment
// and may cause crashes down the line if not
// properly typed.
const hookResult = await dispatch(runDesktopNotificationHooks(post, msgProps, channel as any, teamId, args));
if (hookResult.error) {
dispatch(logError(hookResult.error as ServerError));
return {data: {status: 'error', reason: 'desktop_notification_hook', data: String(hookResult.error)}};
}
const argsAfterHooks = hookResult.data!;
if (!argsAfterHooks.notify) {
return {data: {status: 'not_sent', reason: 'desktop_notification_hook', data: String(hookResult)}};
}
const result = dispatch(notifyMe(argsAfterHooks.title, argsAfterHooks.body, channel.id, teamId, argsAfterHooks.silent, argsAfterHooks.soundName, argsAfterHooks.url));
//Don't add extra sounds on native desktop clients
if (desktopSoundEnabled && !isDesktopApp() && !isMobileApp()) {
ding(soundName);
}
return result;
};
}
const getNotificationTitle = (channel: Pick<Channel, 'type' | 'display_name'>, msgProps: NewPostMessageProps, isCrtReply: boolean) => {
let title = Utils.localizeMessage({id: 'channel_loader.posted', defaultMessage: 'Posted'});
if (channel.type === Constants.DM_CHANNEL) {
title = Utils.localizeMessage({id: 'notification.dm', defaultMessage: 'Direct Message'});
} else {
title = channel.display_name;
}
if (title === '') {
if (msgProps.channel_type === Constants.DM_CHANNEL) {
title = Utils.localizeMessage({id: 'notification.dm', defaultMessage: 'Direct Message'});
} else {
title = msgProps.channel_display_name;
}
}
if (isCrtReply) {
title = Utils.localizeAndFormatMessage({id: 'notification.crt', defaultMessage: 'Reply in {title}'}, {title});
}
return title;
};
const getNotificationUsername = (state: GlobalState, post: Post, msgProps: NewPostMessageProps) => {
const config = getConfig(state);
const userFromPost = getUser(state, post.user_id);
if (post.props.override_username && config.EnablePostUsernameOverride === 'true') {
return post.props.override_username;
}
if (userFromPost) {
return displayUsername(userFromPost, getTeammateNameDisplaySetting(state), false);
}
if (msgProps.sender_name) {
return msgProps.sender_name;
}
return Utils.localizeMessage({id: 'channel_loader.someone', defaultMessage: 'Someone'});
};
const getNotificationBody = (state: GlobalState, post: Post, msgProps: NewPostMessageProps) => {
const username = getNotificationUsername(state, post, msgProps);
let notifyText = post.message;
const msgPropsPost: Post = JSON.parse(msgProps.post);
const attachments: MessageAttachment[] = msgPropsPost && msgPropsPost.props && msgPropsPost.props.attachments ? msgPropsPost.props.attachments : [];
let image = false;
attachments.forEach((attachment) => {
if (notifyText.length === 0) {
notifyText = attachment.fallback ||
attachment.pretext ||
attachment.text;
}
image = image || (attachment.image_url.length > 0);
});
const strippedMarkdownNotifyText = stripMarkdown(notifyText);
let body = `@${username}`;
if (strippedMarkdownNotifyText.length === 0) {
if (msgProps.image) {
body += Utils.localizeMessage({id: 'channel_loader.uploadedImage', defaultMessage: ' uploaded an image'});
} else if (msgProps.otherFile) {
body += Utils.localizeMessage({id: 'channel_loader.uploadedFile', defaultMessage: ' uploaded a file'});
} else if (image) {
body += Utils.localizeMessage({id: 'channel_loader.postedImage', defaultMessage: ' posted an image'});
} else {
body += Utils.localizeMessage({id: 'channel_loader.something', defaultMessage: ' did something new'});
}
} else {
body += `: ${strippedMarkdownNotifyText}`;
}
return body;
};
function shouldSkipNotification(
state: GlobalState,
post: Post,
msgProps: NewPostMessageProps,
user: UserProfile,
channel: Pick<Channel, 'type' | 'id'>,
member: ChannelMembership | undefined,
isCrtReply: boolean,
) {
const currentUserId = getCurrentUserId(state);
if ((currentUserId === post.user_id && post.props.from_webhook !== 'true')) {
return {status: 'not_sent', reason: 'own_post'};
}
if (isSystemMessage(post) && !isUserAddedInChannel(post, currentUserId)) {
return {status: 'not_sent', reason: 'system_message'};
}
if (!member) {
return {status: 'error', reason: 'no_member'};
}
if (isChannelMuted(member)) {
return {status: 'not_sent', reason: 'channel_muted'};
}
const userStatus = getStatusForUserId(state, user.id);
if ((userStatus === UserStatuses.DND || userStatus === UserStatuses.OUT_OF_OFFICE)) {
return {status: 'not_sent', reason: 'user_status', data: userStatus};
}
let mentions = [];
if (msgProps.mentions) {
mentions = JSON.parse(msgProps.mentions);
}
let followers = [];
if (msgProps.followers) {
followers = JSON.parse(msgProps.followers);
mentions = [...new Set([...followers, ...mentions])];
}
const channelNotifyProp = member?.notify_props?.desktop || NotificationLevels.DEFAULT;
let notifyLevel = channelNotifyProp;
if (notifyLevel === NotificationLevels.DEFAULT) {
notifyLevel = user?.notify_props?.desktop || NotificationLevels.ALL;
}
if (channel?.type === 'G' && channelNotifyProp === NotificationLevels.DEFAULT && user?.notify_props?.desktop === NotificationLevels.MENTION) {
notifyLevel = NotificationLevels.ALL;
}
if (notifyLevel === NotificationLevels.NONE) {
return {status: 'not_sent', reason: 'notify_level_none'};
} else if (channel?.type === 'G' && notifyLevel === NotificationLevels.MENTION) {
// Compose the whole text in the message, including interactive messages.
let text = post.message;
// We do this on a try catch block to avoid errors from malformed props
try {
if (post.props && post.props.attachments) {
const attachments = post.props.attachments;
function appendText(toAppend: string) {
if (toAppend) {
text += `\n${toAppend}`;
}
}
for (const attachment of attachments) {
appendText(attachment.pretext);
appendText(attachment.title);
appendText(attachment.text);
appendText(attachment.footer);
if (attachment.fields) {
for (const field of attachment.fields) {
appendText(field.title);
appendText(field.value);
}
}
}
}
} catch (e) {
// eslint-disable-next-line no-console
console.log('Could not process the whole attachment for mentions', e);
}
const allMentions = getAllUserMentionKeys(state);
const ignoreChannelMentionProp = member?.notify_props?.ignore_channel_mentions || IgnoreChannelMentions.DEFAULT;
let ignoreChannelMention = ignoreChannelMentionProp === IgnoreChannelMentions.ON;
if (ignoreChannelMentionProp === IgnoreChannelMentions.DEFAULT) {
ignoreChannelMention = user?.notify_props?.channel === 'false';
}
const mentionableText = formatWithRenderer(text, new MentionableRenderer());
let isExplicitlyMentioned = false;
for (const mention of allMentions) {
if (!mention || !mention.key) {
continue;
}
if (ignoreChannelMention && ['@all', '@here', '@channel'].includes(mention.key)) {
continue;
}
let flags = 'g';
if (!mention.caseSensitive) {
flags += 'i';
}
let pattern;
if (cjkrPattern.test(mention.key)) {
// In the case of CJK mention key, even if there's no delimiters (such as spaces) at both ends of a word, it is recognized as a mention key
pattern = new RegExp(`()(${escapeRegex(mention.key)})()`, flags);
} else {
pattern = new RegExp(
`(^|\\W)(${escapeRegex(mention.key)})(\\b|_+\\b)`,
flags,
);
}
if (pattern.test(mentionableText)) {
isExplicitlyMentioned = true;
break;
}
}
if (!isExplicitlyMentioned) {
return {status: 'not_sent', reason: 'not_explicitly_mentioned', data: mentionableText};
}
} else if (notifyLevel === NotificationLevels.MENTION && mentions.indexOf(user.id) === -1 && msgProps.channel_type !== Constants.DM_CHANNEL) {
return {status: 'not_sent', reason: 'not_mentioned'};
} else if (isCrtReply && notifyLevel === NotificationLevels.ALL && followers.indexOf(currentUserId) === -1) {
// if user is not following the thread don't notify
return {status: 'not_sent', reason: 'not_following_thread'};
}
// Notify if you're not looking in the right channel or when
// the window itself is not active
const activeChannel = getCurrentChannel(state);
const channelId = channel ? channel.id : null;
if (state.views.browser.focused) {
if (isCrtReply) {
if (isThreadOpen(state, post.root_id)) {
return {status: 'not_sent', reason: 'thread_is_open', data: post.root_id};
}
} else if (activeChannel && activeChannel.id === channelId) {
return {status: 'not_sent', reason: 'channel_is_open', data: activeChannel?.id};
}
}
return undefined;
}
export function notifyMe(title: string, body: string, channelId: string, teamId: string, silent: boolean, soundName: string, url: string): ActionFuncAsync<NotificationResult, GlobalState> {
return async (dispatch) => {
// handle notifications in desktop app
if (isDesktopApp()) {
const result = await DesktopApp.dispatchNotification(title, body, channelId, teamId, silent, soundName, url);
return {data: result};
}
try {
const result = await dispatch(showNotification({
title,
body,
requireInteraction: false,
silent,
onClick: () => {
window.focus();
getHistory().push(url);
},
}));
return {data: result};
} catch (error) {
dispatch(logError(error));
return {data: {status: 'error', reason: 'notification_api', data: String(error)}};
}
};
}

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

@@ -16,6 +16,8 @@ import * as PostUtils from 'utils/post_utils';
import type {GlobalState} from 'types/store';
import {sendDesktopNotification} from './notification_actions';
jest.mock('mattermost-redux/actions/posts', () => ({
removeReaction: (...args: any[]) => ({type: 'MOCK_REMOVE_REACTION', args}),
addReaction: (...args: any[]) => ({type: 'MOCK_ADD_REACTION', args}),
@@ -34,7 +36,7 @@ jest.mock('actions/emoji_actions', () => ({
}));
jest.mock('actions/notification_actions', () => ({
sendDesktopNotification: jest.fn().mockReturnValue({type: 'MOCK_SEND_DESKTOP_NOTIFICATION'}),
sendDesktopNotification: jest.fn().mockReturnValue(() => ({data: {}})),
}));
jest.mock('actions/storage', () => {
@@ -58,6 +60,8 @@ jest.mock('utils/post_utils', () => ({
const mockMakeGetIsReactionAlreadyAddedToPost = PostUtils.makeGetIsReactionAlreadyAddedToPost as unknown as jest.Mock<() => boolean>;
const mockMakeGetUniqueEmojiNameReactionsForPost = PostUtils.makeGetUniqueEmojiNameReactionsForPost as unknown as jest.Mock<() => string[]>;
const mockedSendDesktopNotification = jest.mocked(sendDesktopNotification);
const POST_CREATED_TIME = Date.now();
// This mocks the Date.now() function so it returns a constant value
@@ -213,10 +217,8 @@ describe('Actions.Posts', () => {
],
type: 'BATCHING_REDUCER.BATCH',
},
{
type: 'MOCK_SEND_DESKTOP_NOTIFICATION',
},
]);
expect(mockedSendDesktopNotification).toHaveBeenCalled();
});
test('handleNewPostOtherChannel', async () => {
@@ -263,10 +265,8 @@ describe('Actions.Posts', () => {
],
type: 'BATCHING_REDUCER.BATCH',
},
{
type: 'MOCK_SEND_DESKTOP_NOTIFICATION',
},
]);
expect(mockedSendDesktopNotification).toHaveBeenCalled();
});
test('unsetEditingPost', async () => {

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

@@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {CommandArgs} from '@mattermost/types/integrations';
import type {Post} from '@mattermost/types/posts';
import type {CreatePostReturnType, SubmitReactionReturnType} from 'mattermost-redux/actions/posts';
@@ -83,7 +84,7 @@ export function submitPost(
return {error: hookResult.error};
}
post = hookResult.data;
post = hookResult.data!;
return dispatch(PostActions.createPost(post, draft.fileInfos, afterSubmit, afterOptimisticSubmit));
};
@@ -97,7 +98,7 @@ export function submitCommand(channelId: string, rootId: string, draft: PostDraf
const teamId = getCurrentTeamId(state);
let args = {
let args: CommandArgs = {
channel_id: channelId,
team_id: teamId,
root_id: rootId,

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

@@ -100,7 +100,7 @@ import {
} from 'actions/cloud';
import {loadCustomEmojisIfNeeded} from 'actions/emoji_actions';
import {redirectUserToDefaultTeam} from 'actions/global_actions';
import {sendDesktopNotification} from 'actions/notification_actions.jsx';
import {sendDesktopNotification} from 'actions/notification_actions';
import {handleNewPost} from 'actions/post_actions';
import * as StatusActions from 'actions/status_actions';
import {setGlobalItem} from 'actions/storage';

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

@@ -10,7 +10,7 @@ import {Posts} from 'mattermost-redux/constants';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import type {PluginComponent} from 'types/store/plugins';
import type {MessageWillFormatHook} from 'types/store/plugins';
import PostMarkdown from './post_markdown';
@@ -235,7 +235,7 @@ describe('components/PostMarkdown', () => {
return updatedMessage + '!';
},
},
] as PluginComponent[],
] as MessageWillFormatHook[],
};
renderWithContext(<PostMarkdown {...props}/>, state);
expect(screen.queryByText('world', {exact: true})).not.toBeInTheDocument();
@@ -269,7 +269,7 @@ describe('components/PostMarkdown', () => {
return post.message + '!';
},
},
] as PluginComponent[],
] as unknown as MessageWillFormatHook[],
};
renderWithContext(<PostMarkdown {...props}/>, state);
expect(screen.queryByText('world', {exact: true})).not.toBeInTheDocument();

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

@@ -212,15 +212,15 @@ export function createGroupChannel(userIds: string[]): ActionFuncAsync<Channel>
// posts is because it existed before.
if (created.total_msg_count > 0) {
const storeMember = getMyChannelMemberSelector(getState(), created.id);
if (storeMember === null) {
if (storeMember) {
member = storeMember;
} else {
try {
member = await Client4.getMyChannelMember(created.id);
} catch (error) {
// Log the error and keep going with the generated membership.
dispatch(logError(error));
}
} else {
member = storeMember;
}
}
@@ -1232,7 +1232,7 @@ export function actionsToMarkChannelAsRead(getState: GetStateFunc, channelId: st
return actions;
}
export function actionsToMarkChannelAsUnread(getState: GetStateFunc, teamId: string, channelId: string, mentions: string[], fetchedChannelMember = false, isRoot = false, priority = '') {
export function actionsToMarkChannelAsUnread(getState: GetStateFunc, teamId: string, channelId: string, mentions: string, fetchedChannelMember = false, isRoot = false, priority = '') {
const state = getState();
const {myMembers} = state.entities.channels;
const {currentUserId} = state.entities.users;

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

@@ -262,12 +262,12 @@ export const getCurrentChannelNameForSearchShortcut: (state: GlobalState) => str
},
);
export const getMyChannelMember: (state: GlobalState, channelId: string) => ChannelMembership | undefined | null = createSelector(
export const getMyChannelMember: (state: GlobalState, channelId: string) => ChannelMembership | undefined = createSelector(
'getMyChannelMember',
getMyChannelMemberships,
(state: GlobalState, channelId: string): string => channelId,
(channelMemberships: RelationOneToOne<Channel, ChannelMembership>, channelId: string): ChannelMembership | undefined | null => {
return channelMemberships[channelId] || null;
(channelMemberships: RelationOneToOne<Channel, ChannelMembership>, channelId: string): ChannelMembership | undefined => {
return channelMemberships[channelId];
},
);

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

@@ -365,7 +365,7 @@ export function channelListToMap(channelList: Channel[]): IDMappedObjects<Channe
// or not that channel would be shown as unread in the sidebar.
export function calculateUnreadCount(
messageCount: ChannelMessageCount | undefined,
member: ChannelMembership | null | undefined,
member: ChannelMembership | undefined,
crtEnabled: boolean,
): {showUnread: boolean; mentions: number; messages: number; hasUrgent: boolean} {
if (!member || !messageCount) {

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

@@ -14,6 +14,9 @@ exports[`plugins/Pluggable should match snapshot with extended component 1`] = `
"FilesWillUploadHook": Array [],
"LinkTooltip": Array [],
"MainMenu": Array [],
"MessageWillBePosted": Array [],
"MessageWillBeUpdated": Array [],
"MessageWillFormat": Array [],
"MobileChannelHeaderButton": Array [],
"NeedsTeamComponent": Array [],
"NewMessagesSeparatorAction": Array [],
@@ -29,6 +32,7 @@ exports[`plugins/Pluggable should match snapshot with extended component 1`] = `
"PostEditorAction": Array [],
"Product": Array [],
"RightHandSidebarComponent": Array [],
"SlashCommandWillBePosted": Array [],
"UserGuideDropdownItem": Array [],
}
}
@@ -148,6 +152,9 @@ exports[`plugins/Pluggable should match snapshot with extended component with pl
"FilesWillUploadHook": Array [],
"LinkTooltip": Array [],
"MainMenu": Array [],
"MessageWillBePosted": Array [],
"MessageWillBeUpdated": Array [],
"MessageWillFormat": Array [],
"MobileChannelHeaderButton": Array [],
"NeedsTeamComponent": Array [],
"NewMessagesSeparatorAction": Array [],
@@ -163,6 +170,7 @@ exports[`plugins/Pluggable should match snapshot with extended component with pl
"PostEditorAction": Array [],
"Product": Array [],
"RightHandSidebarComponent": Array [],
"SlashCommandWillBePosted": Array [],
"UserGuideDropdownItem": Array [],
}
}
@@ -282,6 +290,9 @@ exports[`plugins/Pluggable should match snapshot with no extended component 1`]
"FilesWillUploadHook": Array [],
"LinkTooltip": Array [],
"MainMenu": Array [],
"MessageWillBePosted": Array [],
"MessageWillBeUpdated": Array [],
"MessageWillFormat": Array [],
"MobileChannelHeaderButton": Array [],
"NeedsTeamComponent": Array [],
"NewMessagesSeparatorAction": Array [],
@@ -290,6 +301,7 @@ exports[`plugins/Pluggable should match snapshot with no extended component 1`]
"PostEditorAction": Array [],
"Product": Array [],
"RightHandSidebarComponent": Array [],
"SlashCommandWillBePosted": Array [],
"UserGuideDropdownItem": Array [],
}
}
@@ -341,6 +353,9 @@ exports[`plugins/Pluggable should match snapshot with non-null pluggableId 1`] =
"FilesWillUploadHook": Array [],
"LinkTooltip": Array [],
"MainMenu": Array [],
"MessageWillBePosted": Array [],
"MessageWillBeUpdated": Array [],
"MessageWillFormat": Array [],
"MobileChannelHeaderButton": Array [],
"NeedsTeamComponent": Array [],
"NewMessagesSeparatorAction": Array [],
@@ -356,6 +371,7 @@ exports[`plugins/Pluggable should match snapshot with non-null pluggableId 1`] =
"PostEditorAction": Array [],
"Product": Array [],
"RightHandSidebarComponent": Array [],
"SlashCommandWillBePosted": Array [],
"UserGuideDropdownItem": Array [],
}
}
@@ -408,6 +424,9 @@ exports[`plugins/Pluggable should match snapshot with null pluggableId 1`] = `
"FilesWillUploadHook": Array [],
"LinkTooltip": Array [],
"MainMenu": Array [],
"MessageWillBePosted": Array [],
"MessageWillBeUpdated": Array [],
"MessageWillFormat": Array [],
"MobileChannelHeaderButton": Array [],
"NeedsTeamComponent": Array [],
"NewMessagesSeparatorAction": Array [],
@@ -423,6 +442,7 @@ exports[`plugins/Pluggable should match snapshot with null pluggableId 1`] = `
"PostEditorAction": Array [],
"Product": Array [],
"RightHandSidebarComponent": Array [],
"SlashCommandWillBePosted": Array [],
"UserGuideDropdownItem": Array [],
}
}
@@ -542,6 +562,9 @@ exports[`plugins/Pluggable should match snapshot with valid pluggableId 1`] = `
"FilesWillUploadHook": Array [],
"LinkTooltip": Array [],
"MainMenu": Array [],
"MessageWillBePosted": Array [],
"MessageWillBeUpdated": Array [],
"MessageWillFormat": Array [],
"MobileChannelHeaderButton": Array [],
"NeedsTeamComponent": Array [],
"NewMessagesSeparatorAction": Array [],
@@ -557,6 +580,7 @@ exports[`plugins/Pluggable should match snapshot with valid pluggableId 1`] = `
"PostEditorAction": Array [],
"Product": Array [],
"RightHandSidebarComponent": Array [],
"SlashCommandWillBePosted": Array [],
"UserGuideDropdownItem": Array [],
}
}

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

@@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ComponentProps} from 'react';
import React from 'react';
import {Preferences} from 'mattermost-redux/constants';
@@ -18,7 +19,7 @@ class ProfilePopoverPlugin extends React.PureComponent {
jest.mock('actions/views/profile_popover');
describe('plugins/Pluggable', () => {
const baseProps = {
const baseProps: ComponentProps<typeof Pluggable> = {
pluggableName: '',
components: {
Product: [],
@@ -40,6 +41,10 @@ describe('plugins/Pluggable', () => {
NeedsTeamComponent: [],
CreateBoardFromTemplate: [],
DesktopNotificationHooks: [],
MessageWillBePosted: [],
MessageWillBeUpdated: [],
MessageWillFormat: [],
SlashCommandWillBePosted: [],
},
theme: Preferences.THEMES.denim,
};

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

@@ -199,6 +199,10 @@ const initialComponents: PluginsState['components'] = {
NeedsTeamComponent: [],
CreateBoardFromTemplate: [],
DesktopNotificationHooks: [],
MessageWillBePosted: [],
MessageWillBeUpdated: [],
MessageWillFormat: [],
SlashCommandWillBePosted: [],
};
function components(state: PluginsState['components'] = initialComponents, action: AnyAction) {

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

@@ -33,7 +33,7 @@ export function getPermalinkURL(state: GlobalState, teamId: Team['id'], postId:
return `${getTeamRelativeUrl(team)}/pl/${postId}`;
}
export function getChannelURL(state: GlobalState, channel: Channel, teamId: string): string {
export function getChannelURL(state: GlobalState, channel: Pick<Channel, 'type' | 'name'>, teamId: string): string {
let notificationURL;
if (channel && (channel.type === Constants.DM_CHANNEL || channel.type === Constants.GM_CHANNEL)) {
notificationURL = getCurrentRelativeTeamUrl(state) + '/channels/' + channel.name;

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

@@ -44,6 +44,10 @@ export type PluginsState = {
NeedsTeamComponent: NeedsTeamComponent[];
CreateBoardFromTemplate: PluginComponent[];
DesktopNotificationHooks: DesktopNotificationHook[];
SlashCommandWillBePosted: SlashCommandWillBePostedHook[];
MessageWillBePosted: MessageWillBePostedHook[];
MessageWillBeUpdated: MessageWillBeUpdatedHook[];
MessageWillFormat: MessageWillFormatHook[];
};
postTypes: {
@@ -112,7 +116,6 @@ export type PluginComponent = {
filter?: (id: string) => boolean;
action?: (...args: any) => void; // TODO Add more concrete types?
shouldRender?: (state: GlobalState) => boolean;
hook?: (post: Post, message?: string) => string;
};
export type AppBarComponent = PluginComponent & {
@@ -260,3 +263,30 @@ export type DesktopNotificationHook = PluginComponent & {
args?: DesktopNotificationArgs;
}>;
}
type SlashCommandWillBePostedArgs = {
channel_id: string;
team_id?: string;
root_id?: string;
}
export type SlashCommandWillBePostedHook = PluginComponent & {
hook: (message: string, args: SlashCommandWillBePostedArgs) => Promise<(
{error: {message: string}} | {message: string; args: SlashCommandWillBePostedArgs} | Record<string, never>
)>;
};
export type MessageWillBePostedHook = PluginComponent & {
hook: (post: Post) => Promise<(
{error: {message: string}} | {post: Post}
)>;
};
export type MessageWillBeUpdatedHook = PluginComponent & {
hook: (newPost: Partial<Post>, oldPost: Post) => Promise<(
{error: {message: string}} | {post: Partial<Post>}
)>;
};
export type MessageWillFormatHook = PluginComponent & {
hook: (post: Post, message: string) => string;
};