From dd519629be14ee19c4068218c0a42b41b735463e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Mon, 28 Oct 2024 17:07:11 +0100 Subject: [PATCH] Migrate hooks and notification actions to typescript (#28858) * Migrate hooks and notification actions to typescript * Remove missing files * Fix hook types * Fix tests --- .../channels/src/actions/global_actions.tsx | 2 +- webapp/channels/src/actions/hooks.test.js | 10 +- .../src/actions/{hooks.js => hooks.ts} | 35 +- webapp/channels/src/actions/new_post.test.ts | 15 +- webapp/channels/src/actions/new_post.ts | 18 +- .../src/actions/notification_actions.jsx | 382 ---------------- .../src/actions/notification_actions.test.js | 12 +- .../src/actions/notification_actions.tsx | 430 ++++++++++++++++++ .../channels/src/actions/post_actions.test.ts | 14 +- .../src/actions/views/create_comment.tsx | 5 +- .../src/actions/websocket_actions.jsx | 2 +- .../post_markdown/post_markdown.test.tsx | 6 +- .../mattermost-redux/src/actions/channels.ts | 8 +- .../src/selectors/entities/channels.ts | 6 +- .../src/utils/channel_utils.ts | 2 +- .../__snapshots__/pluggable.test.tsx.snap | 24 + .../src/plugins/pluggable/pluggable.test.tsx | 7 +- webapp/channels/src/reducers/plugins/index.ts | 4 + webapp/channels/src/selectors/urls.ts | 2 +- webapp/channels/src/types/store/plugins.ts | 32 +- 20 files changed, 586 insertions(+), 430 deletions(-) rename webapp/channels/src/actions/{hooks.js => hooks.ts} (66%) delete mode 100644 webapp/channels/src/actions/notification_actions.jsx create mode 100644 webapp/channels/src/actions/notification_actions.tsx diff --git a/webapp/channels/src/actions/global_actions.tsx b/webapp/channels/src/actions/global_actions.tsx index 593966eda5409..82c68c4ddd6db 100644 --- a/webapp/channels/src/actions/global_actions.tsx +++ b/webapp/channels/src/actions/global_actions.tsx @@ -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); } diff --git a/webapp/channels/src/actions/hooks.test.js b/webapp/channels/src/actions/hooks.test.js index 971b8532ebca9..230a3ed944543 100644 --- a/webapp/channels/src/actions/hooks.test.js +++ b/webapp/channels/src/actions/hooks.test.js @@ -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); }); }); diff --git a/webapp/channels/src/actions/hooks.js b/webapp/channels/src/actions/hooks.ts similarity index 66% rename from webapp/channels/src/actions/hooks.js rename to webapp/channels/src/actions/hooks.ts index abbb4a13e5f49..00655cdef34ec 100644 --- a/webapp/channels/src/actions/hooks.js +++ b/webapp/channels/src/actions/hooks.ts @@ -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} */ -export function runMessageWillBePostedHooks(originalPost) { +export function runMessageWillBePostedHooks(originalPost: Post): ActionFuncAsync { 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, oldPost: Post): ActionFuncAsync, 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 { 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}; }; } diff --git a/webapp/channels/src/actions/new_post.test.ts b/webapp/channels/src/actions/new_post.test.ts index 24c1a0085024b..1b9009c03f4d4 100644 --- a/webapp/channels/src/actions/new_post.test.ts +++ b/webapp/channels/src/actions/new_post.test.ts @@ -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([ diff --git a/webapp/channels/src/actions/new_post.ts b/webapp/channels/src/actions/new_post.ts index c136f5638829b..c5a75d9e8ee0b 100644 --- a/webapp/channels/src/actions/new_post.ts +++ b/webapp/channels/src/actions/new_post.ts @@ -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 { @@ -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 { diff --git a/webapp/channels/src/actions/notification_actions.jsx b/webapp/channels/src/actions/notification_actions.jsx deleted file mode 100644 index 88a5ca60c4a42..0000000000000 --- a/webapp/channels/src/actions/notification_actions.jsx +++ /dev/null @@ -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, 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, 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)}; - } -}; diff --git a/webapp/channels/src/actions/notification_actions.test.js b/webapp/channels/src/actions/notification_actions.test.js index e0d70971a3f0e..727e38259880d 100644 --- a/webapp/channels/src/actions/notification_actions.test.js +++ b/webapp/channels/src/actions/notification_actions.test.js @@ -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: { diff --git a/webapp/channels/src/actions/notification_actions.tsx b/webapp/channels/src/actions/notification_actions.tsx new file mode 100644 index 0000000000000..6087c780e977a --- /dev/null +++ b/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 { + 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, 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, + 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 { + 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)}}; + } + }; +} diff --git a/webapp/channels/src/actions/post_actions.test.ts b/webapp/channels/src/actions/post_actions.test.ts index 1cb5342b774c4..1eb5e2cf4d6dc 100644 --- a/webapp/channels/src/actions/post_actions.test.ts +++ b/webapp/channels/src/actions/post_actions.test.ts @@ -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 () => { diff --git a/webapp/channels/src/actions/views/create_comment.tsx b/webapp/channels/src/actions/views/create_comment.tsx index b988340e68f5b..1739030c58ee9 100644 --- a/webapp/channels/src/actions/views/create_comment.tsx +++ b/webapp/channels/src/actions/views/create_comment.tsx @@ -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, diff --git a/webapp/channels/src/actions/websocket_actions.jsx b/webapp/channels/src/actions/websocket_actions.jsx index b8a1323db9c5e..566ff74a47b77 100644 --- a/webapp/channels/src/actions/websocket_actions.jsx +++ b/webapp/channels/src/actions/websocket_actions.jsx @@ -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'; diff --git a/webapp/channels/src/components/post_markdown/post_markdown.test.tsx b/webapp/channels/src/components/post_markdown/post_markdown.test.tsx index ef617eb53cb00..f5b8fa77c77c9 100644 --- a/webapp/channels/src/components/post_markdown/post_markdown.test.tsx +++ b/webapp/channels/src/components/post_markdown/post_markdown.test.tsx @@ -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(, 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(, state); expect(screen.queryByText('world', {exact: true})).not.toBeInTheDocument(); diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts index 6f17bd877040c..9b9ea3733f02e 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts @@ -212,15 +212,15 @@ export function createGroupChannel(userIds: string[]): ActionFuncAsync // 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; diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.ts index 8997f044b4c93..fdb4014a59315 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.ts @@ -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, channelId: string): ChannelMembership | undefined | null => { - return channelMemberships[channelId] || null; + (channelMemberships: RelationOneToOne, channelId: string): ChannelMembership | undefined => { + return channelMemberships[channelId]; }, ); diff --git a/webapp/channels/src/packages/mattermost-redux/src/utils/channel_utils.ts b/webapp/channels/src/packages/mattermost-redux/src/utils/channel_utils.ts index 43c3952154f6f..b533986b2f11f 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/utils/channel_utils.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/utils/channel_utils.ts @@ -365,7 +365,7 @@ export function channelListToMap(channelList: Channel[]): IDMappedObjects { - const baseProps = { + const baseProps: ComponentProps = { pluggableName: '', components: { Product: [], @@ -40,6 +41,10 @@ describe('plugins/Pluggable', () => { NeedsTeamComponent: [], CreateBoardFromTemplate: [], DesktopNotificationHooks: [], + MessageWillBePosted: [], + MessageWillBeUpdated: [], + MessageWillFormat: [], + SlashCommandWillBePosted: [], }, theme: Preferences.THEMES.denim, }; diff --git a/webapp/channels/src/reducers/plugins/index.ts b/webapp/channels/src/reducers/plugins/index.ts index e2128134339a4..5b9eceb9c291a 100644 --- a/webapp/channels/src/reducers/plugins/index.ts +++ b/webapp/channels/src/reducers/plugins/index.ts @@ -199,6 +199,10 @@ const initialComponents: PluginsState['components'] = { NeedsTeamComponent: [], CreateBoardFromTemplate: [], DesktopNotificationHooks: [], + MessageWillBePosted: [], + MessageWillBeUpdated: [], + MessageWillFormat: [], + SlashCommandWillBePosted: [], }; function components(state: PluginsState['components'] = initialComponents, action: AnyAction) { diff --git a/webapp/channels/src/selectors/urls.ts b/webapp/channels/src/selectors/urls.ts index fcc725d1bfb47..3c531204c0bda 100644 --- a/webapp/channels/src/selectors/urls.ts +++ b/webapp/channels/src/selectors/urls.ts @@ -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, teamId: string): string { let notificationURL; if (channel && (channel.type === Constants.DM_CHANNEL || channel.type === Constants.GM_CHANNEL)) { notificationURL = getCurrentRelativeTeamUrl(state) + '/channels/' + channel.name; diff --git a/webapp/channels/src/types/store/plugins.ts b/webapp/channels/src/types/store/plugins.ts index 030b6a6f55792..5095655e0ba07 100644 --- a/webapp/channels/src/types/store/plugins.ts +++ b/webapp/channels/src/types/store/plugins.ts @@ -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 + )>; +}; + +export type MessageWillBePostedHook = PluginComponent & { + hook: (post: Post) => Promise<( + {error: {message: string}} | {post: Post} + )>; +}; + +export type MessageWillBeUpdatedHook = PluginComponent & { + hook: (newPost: Partial, oldPost: Post) => Promise<( + {error: {message: string}} | {post: Partial} + )>; +}; + +export type MessageWillFormatHook = PluginComponent & { + hook: (post: Post, message: string) => string; +};