Migrate hooks and notification actions to typescript (#28858)
* Migrate hooks and notification actions to typescript * Remove missing files * Fix hook types * Fix tests
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
f9bd093649
Коммит
dd519629be
@@ -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: {
|
||||
|
||||
430
webapp/channels/src/actions/notification_actions.tsx
Обычный файл
430
webapp/channels/src/actions/notification_actions.tsx
Обычный файл
@@ -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;
|
||||
};
|
||||
|
||||
Ссылка в новой задаче
Block a user