diff --git a/webapp/channels/src/actions/notification_actions.tsx b/webapp/channels/src/actions/notification_actions.tsx index c9e592f233..48753bb3f8 100644 --- a/webapp/channels/src/actions/notification_actions.tsx +++ b/webapp/channels/src/actions/notification_actions.tsx @@ -3,7 +3,7 @@ import type {Channel, ChannelMembership} from '@mattermost/types/channels'; import type {ServerError} from '@mattermost/types/errors'; -import type {MessageAttachment} from '@mattermost/types/message_attachments'; +import {isMessageAttachmentArray} from '@mattermost/types/message_attachments'; import type {Post} from '@mattermost/types/posts'; import type {UserProfile} from '@mattermost/types/users'; @@ -19,7 +19,7 @@ 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 {ensureString, isSystemMessage, isUserAddedInChannel} from 'mattermost-redux/utils/post_utils'; import {displayUsername} from 'mattermost-redux/utils/user_utils'; import {getChannelURL, getPermalinkURL} from 'selectors/urls'; @@ -197,12 +197,13 @@ const getNotificationTitle = (channel: Pick, m return title; }; -const getNotificationUsername = (state: GlobalState, post: Post, msgProps: NewPostMessageProps) => { +const getNotificationUsername = (state: GlobalState, post: Post, msgProps: NewPostMessageProps): string => { const config = getConfig(state); const userFromPost = getUser(state, post.user_id); - if (post.props.override_username && config.EnablePostUsernameOverride === 'true') { - return post.props.override_username; + const overrideUsername = ensureString(post.props.override_username); + if (overrideUsername && config.EnablePostUsernameOverride === 'true') { + return overrideUsername; } if (userFromPost) { return displayUsername(userFromPost, getTeammateNameDisplaySetting(state), false); @@ -219,15 +220,15 @@ const getNotificationBody = (state: GlobalState, post: Post, msgProps: NewPostMe let notifyText = post.message; const msgPropsPost: Post = JSON.parse(msgProps.post); - const attachments: MessageAttachment[] = msgPropsPost && msgPropsPost.props && msgPropsPost.props.attachments ? msgPropsPost.props.attachments : []; + const attachments = isMessageAttachmentArray(msgPropsPost?.props?.attachments) ? msgPropsPost.props.attachments : []; let image = false; attachments.forEach((attachment) => { if (notifyText.length === 0) { notifyText = attachment.fallback || attachment.pretext || - attachment.text; + attachment.text || ''; } - image = image || (attachment.image_url.length > 0); + image = Boolean(image || (attachment.image_url?.length)); }); const strippedMarkdownNotifyText = stripMarkdown(notifyText); @@ -316,9 +317,9 @@ function shouldSkipNotification( // We do this on a try catch block to avoid errors from malformed props try { - if (post.props && post.props.attachments) { + if (isMessageAttachmentArray(post.props.attachments)) { const attachments = post.props.attachments; - function appendText(toAppend: string) { + function appendText(toAppend?: string) { if (toAppend) { text += `\n${toAppend}`; } diff --git a/webapp/channels/src/components/custom_open_plugin_install_post_renderer/index.test.tsx b/webapp/channels/src/components/custom_open_plugin_install_post_renderer/index.test.tsx new file mode 100644 index 0000000000..5cb714ee9a --- /dev/null +++ b/webapp/channels/src/components/custom_open_plugin_install_post_renderer/index.test.tsx @@ -0,0 +1,65 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {DeepPartial} from '@mattermost/types/utilities'; + +import {isCustomPostProps, type CustomPostProps} from '.'; + +describe('isCustomPostProps', () => { + it('no content', () => { + const props: CustomPostProps = { + requested_plugins_by_plugin_ids: {}, + requested_plugins_by_user_ids: {}, + }; + expect(isCustomPostProps(props)).toBe(true); + }); + + it('content but no elements', () => { + const props: CustomPostProps = { + requested_plugins_by_plugin_ids: {'some id': []}, + requested_plugins_by_user_ids: {'some id': []}, + }; + expect(isCustomPostProps(props)).toBe(true); + }); + + it('content with elements', () => { + const props: CustomPostProps = { + requested_plugins_by_plugin_ids: {'some id': [{ + user_id: '123', + }]}, + requested_plugins_by_user_ids: {'some id': [{ + user_id: '123', + }]}, + }; + expect(isCustomPostProps(props)).toBe(true); + }); + + it('all values are required', () => { + const baseProp: CustomPostProps = { + requested_plugins_by_plugin_ids: {}, + requested_plugins_by_user_ids: {}, + }; + + expect(isCustomPostProps(baseProp)).toBe(true); + + for (const key of Object.keys(baseProp)) { + const wrongProp: Partial = {...baseProp}; + delete wrongProp[key as keyof CustomPostProps]; + expect(isCustomPostProps(wrongProp)).toBe(false); + } + + const wrongProp: DeepPartial = { + requested_plugins_by_plugin_ids: {'some id': [{}]}, + requested_plugins_by_user_ids: {'some id': []}, + }; + expect(isCustomPostProps(wrongProp)).toBe(false); + }); + + it('common false cases', () => { + expect(isCustomPostProps('')).toBe(false); + expect(isCustomPostProps(undefined)).toBe(false); + expect(isCustomPostProps(true)).toBe(false); + expect(isCustomPostProps(1)).toBe(false); + expect(isCustomPostProps([])).toBe(false); + }); +}); diff --git a/webapp/channels/src/components/custom_open_plugin_install_post_renderer/index.tsx b/webapp/channels/src/components/custom_open_plugin_install_post_renderer/index.tsx index 383fb0c3af..8f534c8cf2 100644 --- a/webapp/channels/src/components/custom_open_plugin_install_post_renderer/index.tsx +++ b/webapp/channels/src/components/custom_open_plugin_install_post_renderer/index.tsx @@ -2,13 +2,14 @@ // See LICENSE.txt for license information. import uniqWith from 'lodash/uniqWith'; -import React, {useEffect, useState} from 'react'; +import React, {useEffect, useMemo} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; import {useSelector, useDispatch} from 'react-redux'; import {Link} from 'react-router-dom'; import type {MarketplacePlugin} from '@mattermost/types/marketplace'; import type {Post} from '@mattermost/types/posts'; +import {isArrayOf, isRecordOf} from '@mattermost/types/utilities'; import {getMissingProfilesByIds} from 'mattermost-redux/actions/users'; import {getUsers} from 'mattermost-redux/selectors/entities/users'; @@ -24,23 +25,52 @@ import {ModalIdentifiers} from 'utils/constants'; import type {GlobalState} from 'types/store'; +// We only define the props used in this component for +// clarity. If more props are needed in the future, +// feel free to add them. type PluginRequest = { user_id: string; - required_feature: string; - required_plan: string; - create_at: string; - sent_at: string; - plugin_name: string; - plugin_id: string; +} + +function isPluginRequest(v: unknown): v is PluginRequest { + if (typeof v !== 'object' || v === null) { + return false; + } + + const request = v as PluginRequest; + + if (typeof request.user_id !== 'string') { + return false; + } + + return true; } type RequestedPlugins = Record -type CustomPostProps = { +export type CustomPostProps = { requested_plugins_by_plugin_ids: RequestedPlugins; requested_plugins_by_user_ids: RequestedPlugins; } +export function isCustomPostProps(v: unknown): v is CustomPostProps { + if (typeof v !== 'object' || !v) { + return false; + } + + const props = v as CustomPostProps; + + if (!isRecordOf(props.requested_plugins_by_plugin_ids, (e) => isArrayOf(e, isPluginRequest))) { + return false; + } + + if (!isRecordOf(props.requested_plugins_by_user_ids, (e) => isArrayOf(e, isPluginRequest))) { + return false; + } + + return true; +} + const usersListStyle = { margin: '20px 0', }; @@ -56,7 +86,7 @@ const InstallLink = (props: {pluginId: string; pluginName: string}) => { > { > ({}); - const postProps = props.post.props as CustomPostProps; + const postProps = isCustomPostProps(props.post.props) ? props.post.props : undefined; const requestedPluginsByPluginIds = postProps?.requested_plugins_by_plugin_ids; const requestedPluginsByUserIds = postProps?.requested_plugins_by_user_ids; const userProfiles = useSelector(getUsers); const marketplacePlugins: MarketplacePlugin[] = useSelector(getPlugins); + const marketplacePluginsNamesById = useMemo(() => { + return marketplacePlugins.reduce>((acc, v) => { + acc[v.manifest.id] = v.manifest.name; + return acc; + }, {}); + }, [marketplacePlugins]); const getUserIdsForUsersThatRequestedFeature = (requests: PluginRequest[]): string[] => requests.map((request: PluginRequest) => request.user_id); @@ -143,32 +178,16 @@ export default function OpenPluginInstallPost(props: {post: Post}) { if (!marketplacePlugins.length) { dispatch(fetchListing()); } - }, [dispatch, fetchListing, marketplacePlugins.length]); + }, [dispatch, marketplacePlugins.length]); useEffect(() => { // process the plugins once the marketplace plugins are fetched and the plugins are available from the props - if (requestedPluginsByPluginIds && marketplacePlugins.length && !Object.keys(pluginsByPluginIds).length) { - const plugins = {} as RequestedPlugins; - const mPlugins = marketplacePlugins.reduce((acc, mPlugin) => { - return { - ...acc, - [mPlugin.manifest.id as keyof string]: mPlugin, - }; - }, {}) as {[ key: string]: MarketplacePlugin}; - + if (requestedPluginsByPluginIds && marketplacePlugins.length) { for (const pluginId of Object.keys(requestedPluginsByPluginIds)) { - plugins[pluginId] = requestedPluginsByPluginIds[pluginId].map((currPlugin: PluginRequest) => { - return { - ...currPlugin, - plugin_name: mPlugins[pluginId].manifest.name || pluginId, - plugin_id: pluginId, - }; - }); dispatch(getMissingProfilesByIds(getUserIdsForUsersThatRequestedFeature(requestedPluginsByPluginIds[pluginId]))); } - setPluginsByPluginIds(plugins); } - }, [dispatch, marketplacePlugins, requestedPluginsByPluginIds, pluginsByPluginIds]); + }, [dispatch, marketplacePlugins, requestedPluginsByPluginIds]); const createUsernameMessage = (requests: PluginRequest[]) => { if (requests.length >= 5) { @@ -209,13 +228,13 @@ export default function OpenPluginInstallPost(props: {post: Post}) { atPlanMentions: true, markdown: false, }; - const pluginIds = Object.keys(pluginsByPluginIds); - if (pluginIds.length && requestedPluginsByUserIds) { + const pluginIds = Object.keys(requestedPluginsByPluginIds || {}); + if (pluginIds.length && requestedPluginsByUserIds && requestedPluginsByPluginIds) { let post; const messageBuilder: string[] = []; const userIds = Object.keys(requestedPluginsByUserIds); if (userIds.length === 1 && pluginIds.length === 1) { - const pluginName = pluginsByPluginIds[pluginIds[0]][0].plugin_name; + const pluginName = marketplacePluginsNamesById[pluginIds[0]]; messageBuilder.push('@' + userProfiles[userIds[0]]?.username); messageBuilder.push(' ' + formatMessage({id: 'postypes.custom_open_plugin_install_post_rendered.plugin_request', defaultMessage: 'requested installing the {pluginRequests} app.'}, {pluginRequests: pluginName})); @@ -258,19 +277,18 @@ export default function OpenPluginInstallPost(props: {post: Post}) { customMessageBody.push(post); } else { messageBuilder.push(formatMessage({id: 'postypes.custom_open_plugin_install_post_rendered.app_installation_request_text', defaultMessage: 'You’ve received the following app installation requests:'})); - const pluginIds = Object.keys(pluginsByPluginIds); - post = (
    {pluginIds.map((pluginId) => { - const plugins = pluginsByPluginIds[pluginId]; + const plugins = requestedPluginsByPluginIds[pluginId]; + const pluginName = marketplacePluginsNamesById[pluginId]; const uniqueUserRequestsForPlugins = uniqWith(plugins, (one, two) => one.user_id === two.user_id); const installRequests = []; installRequests.push(createUsernameMessage(uniqueUserRequestsForPlugins)); - installRequests.push(' ' + formatMessage({id: 'postypes.custom_open_plugin_install_post_rendered.plugin_request', defaultMessage: 'requested installing the {pluginRequests} app.'}, {pluginRequests: uniqueUserRequestsForPlugins[0].plugin_name})); + installRequests.push(' ' + formatMessage({id: 'postypes.custom_open_plugin_install_post_rendered.plugin_request', defaultMessage: 'requested installing the {pluginRequests} app.'}, {pluginRequests: pluginName})); return (
  • @@ -283,7 +301,7 @@ export default function OpenPluginInstallPost(props: {post: Post}) { {' '}
  • ); diff --git a/webapp/channels/src/components/post/user_profile.tsx b/webapp/channels/src/components/post/user_profile.tsx index 20f9a6e8dc..7db3b7a6c8 100644 --- a/webapp/channels/src/components/post/user_profile.tsx +++ b/webapp/channels/src/components/post/user_profile.tsx @@ -7,6 +7,8 @@ import {FormattedMessage, useIntl} from 'react-intl'; import type {Post} from '@mattermost/types/posts'; +import {ensureString} from 'mattermost-redux/utils/post_utils'; + import PostHeaderCustomStatus from 'components/post_view/post_header_custom_status/post_header_custom_status'; import UserProfile from 'components/user_profile'; import BotTag from 'components/widgets/tag/bot_tag'; @@ -75,7 +77,10 @@ const PostUserProfile = (props: Props): JSX.Element | null => { ); if (isFromWebhook(post)) { - const overwriteName = post.props.override_username && enablePostUsernameOverride ? post.props.override_username : undefined; + const propOverrideName = ensureString(post.props.override_username); + const overwriteName = propOverrideName && enablePostUsernameOverride ? propOverrideName : undefined; + const propOverrideIcon = ensureString(post.props.override_icon_url); + const overwriteIcon = propOverrideIcon || undefined; userProfile = ( { hideStatus={true} overwriteName={overwriteName} colorize={colorize} - overwriteIcon={post.props.override_icon_url || undefined} + overwriteIcon={overwriteIcon} /> ); diff --git a/webapp/channels/src/components/post_edit_history/edited_post_item/__snapshots__/edited_post_item.test.tsx.snap b/webapp/channels/src/components/post_edit_history/edited_post_item/__snapshots__/edited_post_item.test.tsx.snap index 13ce740c4c..6a99a84259 100644 --- a/webapp/channels/src/components/post_edit_history/edited_post_item/__snapshots__/edited_post_item.test.tsx.snap +++ b/webapp/channels/src/components/post_edit_history/edited_post_item/__snapshots__/edited_post_item.test.tsx.snap @@ -216,6 +216,7 @@ exports[`components/post_edit_history/edited_post_item should match snapshot whe > diff --git a/webapp/channels/src/components/post_edit_history/edited_post_item/edited_post_item.tsx b/webapp/channels/src/components/post_edit_history/edited_post_item/edited_post_item.tsx index 72c4f2476b..f9e605ae1d 100644 --- a/webapp/channels/src/components/post_edit_history/edited_post_item/edited_post_item.tsx +++ b/webapp/channels/src/components/post_edit_history/edited_post_item/edited_post_item.tsx @@ -10,6 +10,7 @@ import {CheckIcon} from '@mattermost/compass-icons/components'; import type {Post} from '@mattermost/types/posts'; import type {Theme} from 'mattermost-redux/selectors/entities/preferences'; +import {ensureString} from 'mattermost-redux/utils/post_utils'; import CompassThemeProvider from 'components/compass_theme_provider/compass_theme_provider'; import InfoToast from 'components/info_toast/info_toast'; @@ -131,7 +132,7 @@ const EditedPostItem = ({post, isCurrent = false, postCurrentVersion, theme, act const profileSrc = imageURLForUser(post.user_id); - const overwriteName = post.props ? post.props.override_username : ''; + const overwriteName = ensureString(post.props?.override_username); const postHeader = (
    diff --git a/webapp/channels/src/components/post_markdown/post_markdown.tsx b/webapp/channels/src/components/post_markdown/post_markdown.tsx index b667ac3ec1..c36786f336 100644 --- a/webapp/channels/src/components/post_markdown/post_markdown.tsx +++ b/webapp/channels/src/components/post_markdown/post_markdown.tsx @@ -10,7 +10,7 @@ import {Posts} from 'mattermost-redux/constants'; import Markdown from 'components/markdown'; -import type {TextFormattingOptions} from 'utils/text_formatting'; +import {isChannelNamesMap, type TextFormattingOptions} from 'utils/text_formatting'; import {renderReminderSystemBotMessage, renderSystemMessage, renderWranglerSystemMessage} from './system_message_helpers'; @@ -92,7 +92,7 @@ export default class PostMarkdown extends React.PureComponent { // Proxy images if we have an image proxy and the server hasn't already rewritten the this.props.post's image URLs. const proxyImages = !this.props.post || !this.props.post.message_source || this.props.post.message === this.props.post.message_source; - const channelNamesMap = this.props.post && this.props.post.props && this.props.post.props.channel_mentions; + const channelNamesMap = isChannelNamesMap(this.props.post?.props?.channel_mentions) ? this.props.post?.props?.channel_mentions : undefined; this.props.pluginHooks?.forEach((o) => { if (o && o.hook && this.props.post) { diff --git a/webapp/channels/src/components/post_markdown/system_message_helpers.test.ts b/webapp/channels/src/components/post_markdown/system_message_helpers.test.ts new file mode 100644 index 0000000000..af939743cf --- /dev/null +++ b/webapp/channels/src/components/post_markdown/system_message_helpers.test.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {AddMemberProps} from './system_message_helpers'; +import {isAddMemberProps} from './system_message_helpers'; + +describe('isAddMemberProps', () => { + it('with empty lists', () => { + const prop: AddMemberProps = { + post_id: '', + not_in_channel_user_ids: [], + not_in_channel_usernames: [], + not_in_groups_usernames: [], + }; + + expect(isAddMemberProps(prop)).toBe(true); + }); + + it('with values in lists', () => { + const prop: AddMemberProps = { + post_id: '', + not_in_channel_user_ids: ['hello', 'world'], + not_in_channel_usernames: ['hello', 'world'], + not_in_groups_usernames: ['hello', 'world'], + }; + + expect(isAddMemberProps(prop)).toBe(true); + }); + + it('all values are required', () => { + const baseProp: AddMemberProps = { + post_id: '', + not_in_channel_user_ids: [], + not_in_channel_usernames: [], + not_in_groups_usernames: [], + }; + + expect(isAddMemberProps(baseProp)).toBe(true); + + for (const key of Object.keys(baseProp)) { + const wrongProp: Partial = {...baseProp}; + delete wrongProp[key as keyof AddMemberProps]; + expect(isAddMemberProps(wrongProp)).toBe(false); + } + }); + + it('common false cases', () => { + expect(isAddMemberProps('')).toBe(false); + expect(isAddMemberProps(undefined)).toBe(false); + expect(isAddMemberProps(true)).toBe(false); + expect(isAddMemberProps(1)).toBe(false); + expect(isAddMemberProps([])).toBe(false); + }); +}); diff --git a/webapp/channels/src/components/post_markdown/system_message_helpers.tsx b/webapp/channels/src/components/post_markdown/system_message_helpers.tsx index ffe5b1f4a1..5e86edb24c 100644 --- a/webapp/channels/src/components/post_markdown/system_message_helpers.tsx +++ b/webapp/channels/src/components/post_markdown/system_message_helpers.tsx @@ -8,20 +8,23 @@ import {FormattedDate, FormattedMessage, FormattedTime, defineMessages} from 're import type {Channel} from '@mattermost/types/channels'; import type {Post} from '@mattermost/types/posts'; import type {Team} from '@mattermost/types/teams'; +import {isStringArray} from '@mattermost/types/utilities'; import {General, Posts} from 'mattermost-redux/constants'; -import {isPostEphemeral} from 'mattermost-redux/utils/post_utils'; +import {isUserActivityProp} from 'mattermost-redux/utils/post_list'; +import {ensureNumber, ensureString, isPostEphemeral} from 'mattermost-redux/utils/post_utils'; import Markdown from 'components/markdown'; import CombinedSystemMessage from 'components/post_view/combined_system_message'; import GMConversionMessage from 'components/post_view/gm_conversion_message/gm_conversion_message'; import PostAddChannelMember from 'components/post_view/post_add_channel_member'; -import type {TextFormattingOptions} from 'utils/text_formatting'; +import {isChannelNamesMap, type TextFormattingOptions} from 'utils/text_formatting'; import {getSiteURL} from 'utils/url'; -export function renderUsername(value: string): ReactNode { - const username = (value[0] === '@') ? value : `@${value}`; +export function renderUsername(value: unknown): ReactNode { + const verifiedValue = ensureString(value); + const username = (verifiedValue[0] === '@') ? verifiedValue : `@${verifiedValue}`; const options = { markdown: false, @@ -30,10 +33,11 @@ export function renderUsername(value: string): ReactNode { return renderFormattedText(username, options); } -function renderFormattedText(value: string, options?: Partial, post?: Post): ReactNode { +function renderFormattedText(value: unknown, options?: Partial, post?: Post): ReactNode { + const verifiedValue = ensureString(value); return ( ['values'] = {}; + const id = ensureString(post.props?.TranslationID); + const movedThreadPermalink = ensureString(post.props?.MovedThreadPermalink); + if (movedThreadPermalink) { values = { - link: post.props.MovedThreadPermalink, + link: movedThreadPermalink, }; - if (post.props.NumMessages > 1) { + const numMessages = ensureNumber(post.props.NumMessages); + if (numMessages > 1) { values.number = post.props.NumMessages; } } @@ -533,4 +571,3 @@ export function renderWranglerSystemMessage(post: Post): ReactNode { /> ); } - diff --git a/webapp/channels/src/components/post_profile_picture/index.ts b/webapp/channels/src/components/post_profile_picture/index.ts index cd3b0c2903..f7bff56db4 100644 --- a/webapp/channels/src/components/post_profile_picture/index.ts +++ b/webapp/channels/src/components/post_profile_picture/index.ts @@ -9,6 +9,7 @@ import {Client4} from 'mattermost-redux/client'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {get} from 'mattermost-redux/selectors/entities/preferences'; import {getUser, getStatusForUserId} from 'mattermost-redux/selectors/entities/users'; +import {ensureString} from 'mattermost-redux/utils/post_utils'; import {Preferences} from 'utils/constants'; @@ -26,7 +27,7 @@ function mapStateToProps(state: GlobalState, ownProps: Props) { const user = getUser(state, ownProps.userId); const enablePostIconOverride = config.EnablePostIconOverride === 'true'; const availabilityStatusOnPosts = get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.AVAILABILITY_STATUS_ON_POSTS, Preferences.AVAILABILITY_STATUS_ON_POSTS_DEFAULT); - const overrideIconUrl = enablePostIconOverride && ownProps.post && ownProps.post.props && ownProps.post.props.override_icon_url; + const overrideIconUrl = enablePostIconOverride && ensureString(ownProps.post?.props?.override_icon_url); let overwriteIcon; if (overrideIconUrl) { overwriteIcon = Client4.getAbsoluteUrl(overrideIconUrl); diff --git a/webapp/channels/src/components/post_profile_picture/post_profile_picture.tsx b/webapp/channels/src/components/post_profile_picture/post_profile_picture.tsx index 9d3ff3856c..a4a61c94b6 100644 --- a/webapp/channels/src/components/post_profile_picture/post_profile_picture.tsx +++ b/webapp/channels/src/components/post_profile_picture/post_profile_picture.tsx @@ -6,6 +6,8 @@ import React from 'react'; import type {Post} from '@mattermost/types/posts'; import type {UserProfile} from '@mattermost/types/users'; +import {ensureString} from 'mattermost-redux/utils/post_utils'; + import ProfilePicture from 'components/profile_picture'; import MattermostLogo from 'components/widgets/icons/mattermost_logo'; @@ -53,12 +55,8 @@ export default class PostProfilePicture extends React.PureComponent { getPostIconURL = (defaultURL: string, fromAutoResponder: boolean, fromWebhook: boolean): string => { const {enablePostIconOverride, hasImageProxy, post} = this.props; const postProps = post.props; - let postIconOverrideURL = ''; - let useUserIcon = ''; - if (postProps) { - postIconOverrideURL = postProps.override_icon_url; - useUserIcon = postProps.use_user_icon; - } + const postIconOverrideURL = ensureString(postProps?.override_icon_url); + const useUserIcon = ensureString(postProps?.use_user_icon); if (this.props.compactDisplay) { return ''; @@ -95,9 +93,9 @@ export default class PostProfilePicture extends React.PureComponent { const profileSrc = this.getProfilePictureURL(); const src = this.getPostIconURL(profileSrc, fromAutoResponder, fromWebhook); - const overrideIconEmoji = post.props ? post.props.override_icon_emoji : ''; - const overwriteName = post.props ? post.props.override_username : ''; - const isEmoji = typeof overrideIconEmoji == 'string' && overrideIconEmoji !== ''; + const overrideIconEmoji = ensureString(post.props.override_icon_emoji); + const overwriteName = ensureString(post.props?.override_username); + const isEmoji = overrideIconEmoji !== ''; const status = this.getStatus(fromAutoResponder, fromWebhook, user); return ( diff --git a/webapp/channels/src/components/post_view/combined_system_message/combined_system_message.tsx b/webapp/channels/src/components/post_view/combined_system_message/combined_system_message.tsx index b1d2077da2..324d794da4 100644 --- a/webapp/channels/src/components/post_view/combined_system_message/combined_system_message.tsx +++ b/webapp/channels/src/components/post_view/combined_system_message/combined_system_message.tsx @@ -8,6 +8,7 @@ import type {IntlShape, MessageDescriptor} from 'react-intl'; import type {UserProfile} from '@mattermost/types/users'; import {Posts} from 'mattermost-redux/constants'; +import type {MessageData} from 'mattermost-redux/utils/post_list'; import Markdown from 'components/markdown'; @@ -189,11 +190,7 @@ export type Props = { currentUserId: string; currentUsername: string; intl: IntlShape; - messageData: Array<{ - actorId?: string; - postType: string; - userIds: string[]; - }>; + messageData: MessageData[]; showJoinLeave: boolean; userProfiles: UserProfile[]; actions: { diff --git a/webapp/channels/src/components/post_view/commented_on/commented_on.tsx b/webapp/channels/src/components/post_view/commented_on/commented_on.tsx index b84b185c00..037862d72c 100644 --- a/webapp/channels/src/components/post_view/commented_on/commented_on.tsx +++ b/webapp/channels/src/components/post_view/commented_on/commented_on.tsx @@ -4,6 +4,7 @@ import React, {memo} from 'react'; import {FormattedMessage} from 'react-intl'; +import {isMessageAttachmentArray} from '@mattermost/types/message_attachments'; import type {Post} from '@mattermost/types/posts'; import type {UserProfile as UserProfileType} from '@mattermost/types/users'; @@ -29,7 +30,7 @@ function CommentedOn({post, parentPostUser, onCommentClick}: Props) { message = ( ); - } else if (post.props?.attachments?.length > 0) { + } else if (isMessageAttachmentArray(post.props?.attachments) && post.props.attachments.length > 0) { const attachment = post.props.attachments[0]; const webhookMessage = attachment.pretext || attachment.title || attachment.text || attachment.fallback || ''; message = Utils.replaceHtmlEntities(webhookMessage); diff --git a/webapp/channels/src/components/post_view/gm_conversion_message/gm_conversion_message.tsx b/webapp/channels/src/components/post_view/gm_conversion_message/gm_conversion_message.tsx index bbadeeac02..e065c334c4 100644 --- a/webapp/channels/src/components/post_view/gm_conversion_message/gm_conversion_message.tsx +++ b/webapp/channels/src/components/post_view/gm_conversion_message/gm_conversion_message.tsx @@ -6,6 +6,7 @@ import {FormattedMessage, useIntl} from 'react-intl'; import {useDispatch, useSelector} from 'react-redux'; import type {Post} from '@mattermost/types/posts'; +import {isStringArray} from '@mattermost/types/utilities'; import {getMissingProfilesByIds} from 'mattermost-redux/actions/users'; import {makeGetProfilesByIdsAndUsernames} from 'mattermost-redux/selectors/entities/users'; @@ -19,7 +20,7 @@ export type Props = { } function GMConversionMessage(props: Props): JSX.Element { const convertedByUserId = props.post.props.convertedByUserId; - const gmMembersDuringConversionIDs = props.post.props.gmMembersDuringConversionIDs as string[]; + const gmMembersDuringConversionIDs = isStringArray(props.post.props.gmMembersDuringConversionIDs) ? props.post.props.gmMembersDuringConversionIDs : []; const dispatch = useDispatch(); const intl = useIntl(); diff --git a/webapp/channels/src/components/post_view/message_attachments/message_attachment/message_attachment.tsx b/webapp/channels/src/components/post_view/message_attachments/message_attachment/message_attachment.tsx index dc992a93d3..8f5ce5d92b 100644 --- a/webapp/channels/src/components/post_view/message_attachments/message_attachment/message_attachment.tsx +++ b/webapp/channels/src/components/post_view/message_attachments/message_attachment/message_attachment.tsx @@ -8,7 +8,6 @@ import type {KeyboardEvent, MouseEvent, CSSProperties} from 'react'; import type {PostAction, PostActionOption} from '@mattermost/types/integration_actions'; import type { MessageAttachment as MessageAttachmentType, - MessageAttachmentField, } from '@mattermost/types/message_attachments'; import type {PostImage} from '@mattermost/types/posts'; @@ -99,6 +98,9 @@ export default class MessageAttachment extends React.PureComponent handleHeightReceivedForThumbUrl = ({height}: {height: number}) => { const {attachment} = this.props; + if (!attachment.thumb_url) { + return; + } if (!this.props.imagesMetadata || (this.props.imagesMetadata && !this.props.imagesMetadata[attachment.thumb_url])) { this.handleHeightReceived(height); } @@ -106,6 +108,9 @@ export default class MessageAttachment extends React.PureComponent handleHeightReceivedForImageUrl = ({height}: {height: number}) => { const {attachment} = this.props; + if (!attachment.image_url) { + return; + } if (!this.props.imagesMetadata || (this.props.imagesMetadata && !this.props.imagesMetadata[attachment.image_url])) { this.handleHeightReceived(height); } @@ -234,7 +239,7 @@ export default class MessageAttachment extends React.PureComponent let nrTables = 0; const markdown = {markdown: false, mentionHighlight: false, atMentions: false}; - fields.forEach((field: MessageAttachmentField, i: number) => { + fields.forEach((field, i) => { if (rowPos === 2 || !(field.short === true) || lastWasLong) { fieldTables.push( (this.props.post.props?.app_bindings, isAppBinding) ? validateBindings(this.props.post.props?.app_bindings) : []; + if (appEmbeds.length) { // TODO Put some log / message if the form is not valid? return ( <> {this.props.children} @@ -184,21 +185,3 @@ export default class PostBodyAdditionalContent extends React.PureComponent) { - if (!props) { - return false; - } - - if (!props.app_bindings) { - return false; - } - - const embeds = props.app_bindings as AppBinding[]; - - if (!embeds.length) { - return false; - } - - return true; -} diff --git a/webapp/channels/src/components/post_view/post_message_preview/post_message_preview.tsx b/webapp/channels/src/components/post_view/post_message_preview/post_message_preview.tsx index 97264b369f..ed2094f401 100644 --- a/webapp/channels/src/components/post_view/post_message_preview/post_message_preview.tsx +++ b/webapp/channels/src/components/post_view/post_message_preview/post_message_preview.tsx @@ -9,6 +9,7 @@ import type {Post} from '@mattermost/types/posts'; import type {UserProfile} from '@mattermost/types/users'; import {General} from 'mattermost-redux/constants'; +import {ensureString} from 'mattermost-redux/utils/post_utils'; import FileAttachmentListContainer from 'components/file_attachment_list'; import PriorityLabel from 'components/post_priority/post_priority_label'; @@ -55,12 +56,8 @@ const PostMessagePreview = (props: Props) => { const getPostIconURL = (defaultURL: string, fromAutoResponder: boolean, fromWebhook: boolean): string => { const {enablePostIconOverride, hasImageProxy, previewPost} = props; const postProps = previewPost?.props; - let postIconOverrideURL = ''; - let useUserIcon = ''; - if (postProps) { - postIconOverrideURL = postProps.override_icon_url; - useUserIcon = postProps.use_user_icon; - } + const postIconOverrideURL = ensureString(postProps?.override_icon_url); + const useUserIcon = ensureString(postProps?.use_user_icon); if (!fromAutoResponder && fromWebhook && !useUserIcon && enablePostIconOverride) { if (postIconOverrideURL && postIconOverrideURL !== '') { @@ -156,6 +153,8 @@ const PostMessagePreview = (props: Props) => { ) : null; + const overwriteName = ensureString(previewPost.props?.override_username); + return ( {
    diff --git a/webapp/channels/src/components/post_view/post_message_view/post_message_view.tsx b/webapp/channels/src/components/post_view/post_message_view/post_message_view.tsx index 03d1537180..adf3b36d2b 100644 --- a/webapp/channels/src/components/post_view/post_message_view/post_message_view.tsx +++ b/webapp/channels/src/components/post_view/post_message_view/post_message_view.tsx @@ -115,7 +115,7 @@ export default class PostMessageView extends React.PureComponent { return {post.message}; } - const postType = post.props && post.props.type ? post.props.type : post.type; + const postType = typeof post.props?.type === 'string' ? post.props.type : post.type; if (pluginPostTypes && pluginPostTypes.hasOwnProperty(postType)) { const PluginComponent = pluginPostTypes[postType].component; diff --git a/webapp/channels/src/components/rhs_card/rhs_card.tsx b/webapp/channels/src/components/rhs_card/rhs_card.tsx index c72d1009b2..0f2b6cdc58 100644 --- a/webapp/channels/src/components/rhs_card/rhs_card.tsx +++ b/webapp/channels/src/components/rhs_card/rhs_card.tsx @@ -10,6 +10,8 @@ import {Link} from 'react-router-dom'; import type {Post} from '@mattermost/types/posts'; +import {ensureString} from 'mattermost-redux/utils/post_utils'; + import {emitCloseRightHandSide} from 'actions/global_actions'; import Markdown from 'components/markdown'; @@ -126,9 +128,10 @@ export default class RhsCard extends React.Component { } if (!content) { + const message = ensureString(selected.props?.card); content = (
    - +
    ); } @@ -140,13 +143,14 @@ export default class RhsCard extends React.Component { disablePopover={true} /> ); - if (selected.props.override_username && this.props.enablePostUsernameOverride) { + const overrideUsername = ensureString(selected.props.override_username); + if (overrideUsername && this.props.enablePostUsernameOverride) { user = ( ); } diff --git a/webapp/channels/src/components/threading/global_threads/thread_item/attachments/attachment_card/index.tsx b/webapp/channels/src/components/threading/global_threads/thread_item/attachments/attachment_card/index.tsx index 02705b4c90..4dc0de5a5b 100644 --- a/webapp/channels/src/components/threading/global_threads/thread_item/attachments/attachment_card/index.tsx +++ b/webapp/channels/src/components/threading/global_threads/thread_item/attachments/attachment_card/index.tsx @@ -8,11 +8,11 @@ import {stripMarkdown} from 'utils/markdown'; import './attachment_card.scss'; type Props = { - fallback: string; - pretext: string; - title: string; - text: string; - author_name: string; + fallback?: string; + pretext?: string; + title?: string; + text?: string; + author_name?: string; } function AttachmentCard({ @@ -28,7 +28,7 @@ function AttachmentCard({ {`${authorName}: ${title}`}
    - {stripMarkdown(text || pretext || fallback)} + {stripMarkdown(text || pretext || fallback || '')}
    ); diff --git a/webapp/channels/src/components/threading/global_threads/thread_item/attachments/index.tsx b/webapp/channels/src/components/threading/global_threads/thread_item/attachments/index.tsx index 97d2991b4a..e555018958 100644 --- a/webapp/channels/src/components/threading/global_threads/thread_item/attachments/index.tsx +++ b/webapp/channels/src/components/threading/global_threads/thread_item/attachments/index.tsx @@ -3,6 +3,7 @@ import React from 'react'; +import {isMessageAttachmentArray} from '@mattermost/types/message_attachments'; import type {Post} from '@mattermost/types/posts'; import AttachmentCard from './attachment_card'; @@ -17,7 +18,7 @@ function Attachment({post}: Props) { return ; } - if (post.props.attachments && post.props.attachments.length) { + if (isMessageAttachmentArray(post.props.attachments) && post.props.attachments.length) { return ; } diff --git a/webapp/channels/src/components/threading/global_threads/thread_item/thread_item.tsx b/webapp/channels/src/components/threading/global_threads/thread_item/thread_item.tsx index 3a4fdd7d0e..ba32a58d20 100644 --- a/webapp/channels/src/components/threading/global_threads/thread_item/thread_item.tsx +++ b/webapp/channels/src/components/threading/global_threads/thread_item/thread_item.tsx @@ -19,6 +19,7 @@ import {getMissingProfilesByIds} from 'mattermost-redux/actions/users'; import {Posts} from 'mattermost-redux/constants'; import {getInt} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; +import {ensureString} from 'mattermost-redux/utils/post_utils'; import {manuallyMarkThreadAsUnread} from 'actions/views/threads'; import {getIsMobileView} from 'selectors/views/browser'; @@ -89,7 +90,7 @@ function ThreadItem({ const tipStep = useSelector((state: GlobalState) => getInt(state, Preferences.CRT_TUTORIAL_STEP, currentUserId)); const showListTutorialTip = tipStep === CrtTutorialSteps.LIST_POPOVER; const msgDeleted = formatMessage({id: 'post_body.deleted', defaultMessage: '(message deleted)'}); - const postAuthor = post.props?.override_username || displayName; + const postAuthor = ensureString(post.props?.override_username) || displayName; useEffect(() => { if (channel?.teammate_id) { diff --git a/webapp/channels/src/components/unreads_status_handler/unreads_status_handler.tsx b/webapp/channels/src/components/unreads_status_handler/unreads_status_handler.tsx index 601b0b37dc..12b09a099b 100644 --- a/webapp/channels/src/components/unreads_status_handler/unreads_status_handler.tsx +++ b/webapp/channels/src/components/unreads_status_handler/unreads_status_handler.tsx @@ -10,6 +10,7 @@ import type {Team} from '@mattermost/types/teams'; import {basicUnreadMeta} from 'mattermost-redux/selectors/entities/channels'; import type {BasicUnreadStatus} from 'mattermost-redux/selectors/entities/channels'; +import {ensureString} from 'mattermost-redux/utils/post_utils'; import faviconDefault16x16 from 'images/favicon/favicon-default-16x16.png'; import faviconDefault24x24 from 'images/favicon/favicon-default-24x24.png'; @@ -146,7 +147,7 @@ export class UnreadsStatusHandlerClass extends React.PureComponent { const link64x64 = document.querySelector('link[rel="icon"][sizes="64x64"]'); const link96x96 = document.querySelector('link[rel="icon"][sizes="96x96"]'); - const getFavicon = (url: string): string => (typeof url === 'string' ? url : ''); + const getFavicon = (url: string): string => ensureString(url); switch (badgeStatus) { case BadgeStatus.Mention: { diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 4595e7a5b6..f31108f25d 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -4724,10 +4724,9 @@ "posts_view.loadMore": "Load More messages", "posts_view.newMsg": "New Messages", "postypes.custom_open_plugin_install_post_rendered.app_installation_request_text": "You’ve received the following app installation requests:", - "postypes.custom_open_plugin_install_post_rendered.plugin_instructions": " or visit the Marketplace to view all plugins.", + "postypes.custom_open_plugin_install_post_rendered.plugin_instructions": " or visit Marketplace to view all plugins.", "postypes.custom_open_plugin_install_post_rendered.plugin_request": "requested installing the {pluginRequests} app.", - "postypes.custom_open_plugin_install_post_rendered.plugins_installed": "{pluginName} is now installed.", - "postypes.custom_open_plugin_install_post_rendered.plugins_instructions": "Install the apps or visit the Marketplace to view all plugins.", + "postypes.custom_open_plugin_install_post_rendered.plugins_instructions": "Install the apps or visit Marketplace to view all plugins.", "postypes.custom_open_pricing_modal_post_renderer.and": "and", "postypes.custom_open_pricing_modal_post_renderer.members": "{members} members", "postypes.custom_open_pricing_modal_post_renderer.membersThatRequested": "Members that requested ", diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.test.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.test.ts index 728db7f6d3..29b885e02e 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.test.ts @@ -659,13 +659,13 @@ describe('Actions.Posts', () => { { fields: [ {title: '@bbb', value: '@ccc'}, - {value: '@ddd'}, + {title: 'some title', value: '@ddd'}, ], }, { fields: [ {title: '@eee', value: '@fff'}, - {value: '@ggg'}, + {title: 'some other title', value: '@ggg'}, ], }, ], diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts index eb91814103..3047b31663 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts @@ -7,6 +7,7 @@ import {batchActions} from 'redux-batched-actions'; import type {Channel, ChannelUnread} from '@mattermost/types/channels'; import type {FetchPaginatedThreadOptions} from '@mattermost/types/client4'; import type {Group} from '@mattermost/types/groups'; +import {isMessageAttachmentArray} from '@mattermost/types/message_attachments'; import type {Post, PostList, PostAcknowledgement} from '@mattermost/types/posts'; import type {Reaction} from '@mattermost/types/reactions'; import type {GlobalState} from '@mattermost/types/store'; @@ -1091,7 +1092,7 @@ export function getNeededAtMentionedUsernamesAndGroups(state: GlobalState, posts // These correspond to the fields searched by getMentionsEnabledFields on the server findNeededUsernamesAndGroups(post.message); - if (post.props?.attachments) { + if (isMessageAttachmentArray(post.props?.attachments)) { for (const attachment of post.props.attachments) { findNeededUsernamesAndGroups(attachment.pretext); findNeededUsernamesAndGroups(attachment.text); diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/posts.test.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/posts.test.ts index 3f20c19b68..7d651a2fa9 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/posts.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/posts.test.ts @@ -1079,7 +1079,7 @@ describe('Selectors.Posts', () => { e: { ...modifiedState.entities.posts.posts.e, props: { - from_webhook: true, + from_webhook: 'true', }, user_id: user1.id, }, diff --git a/webapp/channels/src/packages/mattermost-redux/src/utils/post_list.test.ts b/webapp/channels/src/packages/mattermost-redux/src/utils/post_list.test.ts index 2bb93d9e57..7d2e1397e0 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/utils/post_list.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/utils/post_list.test.ts @@ -1436,7 +1436,7 @@ describe('extractUserActivityData', () => { describe('combineUserActivityData', () => { it('combineUserActivitySystemPost returns null when systemPosts is an empty array', () => { - expect(combineUserActivitySystemPost([])).toBeNull(); + expect(combineUserActivitySystemPost([])).toBeFalsy(); }); it('correctly combine different post types and actorIds by order', () => { const postAddToChannel1 = TestHelper.getPostMock({type: PostTypes.ADD_TO_CHANNEL, user_id: 'user_id_1', props: {addedUserId: 'added_user_id_1', addedUsername: 'added_username_1'}}); diff --git a/webapp/channels/src/packages/mattermost-redux/src/utils/post_list.ts b/webapp/channels/src/packages/mattermost-redux/src/utils/post_list.ts index b08f7035b7..e92048208f 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/utils/post_list.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/utils/post_list.ts @@ -5,6 +5,7 @@ import moment from 'moment-timezone'; import type {ActivityEntry, Post} from '@mattermost/types/posts'; import type {GlobalState} from '@mattermost/types/store'; +import {isStringArray, isArrayOf} from '@mattermost/types/utilities'; import {Posts} from 'mattermost-redux/constants'; import {createSelector} from 'mattermost-redux/selectors/create_selector'; @@ -13,7 +14,7 @@ import type {UserActivityPost} from 'mattermost-redux/selectors/entities/posts'; import {shouldShowJoinLeaveMessages} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; import {createIdsSelector, memoizeResult} from 'mattermost-redux/utils/helpers'; -import {isUserActivityPost, shouldFilterJoinLeavePost, isFromWebhook} from 'mattermost-redux/utils/post_utils'; +import {isUserActivityPost, shouldFilterJoinLeavePost, isFromWebhook, ensureString} from 'mattermost-redux/utils/post_utils'; import {getUserCurrentTimezone} from 'mattermost-redux/utils/timezone_utils'; export const COMBINED_USER_ACTIVITY = 'user-activity-'; @@ -320,7 +321,7 @@ export function makeGenerateCombinedPost(): (state: GlobalState, combinedId: str } export function extractUserActivityData(userActivities: ActivityEntry[]) { - const messageData: any[] = []; + const messageData: MessageData[] = []; const allUserIds: string[] = []; const allUsernames: string[] = []; userActivities.forEach((activity) => { @@ -390,9 +391,9 @@ function isSameActorsInUserActivities(prevActivity: ActivityEntry, curActivity: }); return hasAllActors; } -export function combineUserActivitySystemPost(systemPosts: Post[] = []) { +export function combineUserActivitySystemPost(systemPosts: Post[] = []): UserActivityProp | undefined { if (systemPosts.length === 0) { - return null; + return undefined; } const userActivities: ActivityEntry[] = []; systemPosts.reverse().forEach((post: Post) => { @@ -402,8 +403,12 @@ export function combineUserActivitySystemPost(systemPosts: Post[] = []) { // When combining removed posts, the actorId does not need to be the same for each post. // All removed posts will be combined regardless of their respective actorIds. const isRemovedPost = post.type === Posts.POST_TYPES.REMOVE_FROM_CHANNEL; - const userId = isUsersRelatedPost(postType) ? post.props.addedUserId || post.props.removedUserId : ''; - const username = isUsersRelatedPost(postType) ? post.props.addedUsername || post.props.removedUsername : ''; + const addedUserId = ensureString(post.props?.addedUserId); + const removedUserId = ensureString(post.props?.removedUserId); + const addedUsername = ensureString(post.props?.addedUsername); + const removedUsername = ensureString(post.props?.removedUsername); + const userId = isUsersRelatedPost(postType) ? addedUserId || removedUserId : ''; + const username = isUsersRelatedPost(postType) ? addedUsername || removedUsername : ''; const prevPost = userActivities[userActivities.length - 1]; const isSamePostType = prevPost && prevPost.postType === post.type; const isSameActor = prevPost && prevPost.actorId[0] === post.user_id; @@ -439,3 +444,55 @@ export function combineUserActivitySystemPost(systemPosts: Post[] = []) { return extractUserActivityData(userActivities); } + +export type MessageData = { + actorId?: string; + postType: string; + userIds: string[]; +} + +function isMessageData(v: unknown): v is MessageData { + if (typeof v !== 'object' || !v) { + return false; + } + + if ('actorId' in v && typeof v.actorId !== 'string') { + return false; + } + + if (!('postType' in v) || typeof v.postType !== 'string') { + return false; + } + + if (!('userIds' in v) || !isStringArray(v.userIds)) { + return false; + } + + return true; +} + +type UserActivityProp = { + allUserIds: string[]; + allUsernames: string[]; + messageData: MessageData[]; +} + +export function isUserActivityProp(v: unknown): v is UserActivityProp { + if (typeof v !== 'object' || !v) { + return false; + } + + if (!('allUserIds' in v) || !isStringArray(v.allUserIds)) { + return false; + } + + if (!('allUsernames' in v) || !isStringArray(v.allUsernames)) { + return false; + } + + if (!('messageData' in v) || !isArrayOf(v.messageData, isMessageData)) { + return false; + } + + return true; +} diff --git a/webapp/channels/src/packages/mattermost-redux/src/utils/post_utils.test.ts b/webapp/channels/src/packages/mattermost-redux/src/utils/post_utils.test.ts index 896ab563f5..9bdbb8480d 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/utils/post_utils.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/utils/post_utils.test.ts @@ -448,7 +448,7 @@ describe('PostUtils', () => { const post = TestHelper.getPostMock({ user_id: 'currentUser', props: { - from_webhook: true, + from_webhook: 'true', }, }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/utils/post_utils.ts b/webapp/channels/src/packages/mattermost-redux/src/utils/post_utils.ts index 6bd37aae0f..eac7817237 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/utils/post_utils.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/utils/post_utils.ts @@ -20,7 +20,7 @@ export function isMeMessage(post: Post): boolean { } export function isFromWebhook(post: Post): boolean { - return post.props && post.props.from_webhook; + return post.props?.from_webhook === 'true'; } export function isPostEphemeral(post: Post): boolean { @@ -28,8 +28,8 @@ export function isPostEphemeral(post: Post): boolean { } export function isUserAddedInChannel(post: Post, userId?: UserProfile['id']): boolean { - const postTypeCheck = post.type && (post.type === Posts.POST_TYPES.ADD_TO_CHANNEL); - const userIdCheck = post.props && post.props.addedUserId && (post.props.addedUserId === userId); + const postTypeCheck = Boolean(post.type && (post.type === Posts.POST_TYPES.ADD_TO_CHANNEL)); + const userIdCheck = Boolean(post.props && post.props.addedUserId && (post.props.addedUserId === userId)); return postTypeCheck && userIdCheck; } @@ -169,7 +169,7 @@ export function isPostCommentMention({post, currentUser, threadRepliedToByCurren commentsNotifyLevel = currentUser.notify_props.comments; } - const notCurrentUser = post.user_id !== currentUser.id || (post.props && post.props.from_webhook); + const notCurrentUser = post.user_id !== currentUser.id || isFromWebhook(post); if (notCurrentUser) { if (commentsNotifyLevel === Preferences.COMMENTS_ANY && (threadCreatedByCurrentUser || threadRepliedToByCurrentUser)) { isCommentMention = true; @@ -241,3 +241,11 @@ export function shouldUpdatePost(receivedPost: Post, storedPost?: Post): boolean // The stored post is older than the one we've received return true; } + +export function ensureString(v: unknown) { + return typeof v === 'string' ? v : ''; +} + +export function ensureNumber(v: unknown) { + return typeof v === 'number' ? v : 0; +} diff --git a/webapp/channels/src/utils/post_utils.ts b/webapp/channels/src/utils/post_utils.ts index 7b05b556c5..b6d399e7c5 100644 --- a/webapp/channels/src/utils/post_utils.ts +++ b/webapp/channels/src/utils/post_utils.ts @@ -10,6 +10,7 @@ import type {Channel} from '@mattermost/types/channels'; import type {ClientConfig, ClientLicense} from '@mattermost/types/config'; import type {ServerError} from '@mattermost/types/errors'; import type {Group} from '@mattermost/types/groups'; +import {isMessageAttachmentArray} from '@mattermost/types/message_attachments'; import type {Post, PostPriorityMetadata} from '@mattermost/types/posts'; import {PostPriority} from '@mattermost/types/posts'; import type {Reaction} from '@mattermost/types/reactions'; @@ -59,7 +60,7 @@ export function fromAutoResponder(post: Post): boolean { } export function isFromWebhook(post: Post): boolean { - return post.props && post.props.from_webhook === 'true'; + return post.props?.from_webhook === 'true'; } export function isFromBot(post: Post): boolean { @@ -545,7 +546,7 @@ export function createAriaLabelForPost(post: Post, author: string, isFlagged: bo } let attachmentCount = 0; - if (post.props && post.props.attachments) { + if (isMessageAttachmentArray(post.props?.attachments)) { attachmentCount += post.props.attachments.length; } if (post.file_ids) { diff --git a/webapp/channels/src/utils/text_formatting.test.ts b/webapp/channels/src/utils/text_formatting.test.ts index 4635bf8dcf..494fb6c87d 100644 --- a/webapp/channels/src/utils/text_formatting.test.ts +++ b/webapp/channels/src/utils/text_formatting.test.ts @@ -3,6 +3,8 @@ import emojiRegex from 'emoji-regex'; +import type {DeepPartial} from '@mattermost/types/utilities'; + import {getEmojiMap} from 'selectors/emojis'; import store from 'stores/redux_store'; @@ -21,6 +23,7 @@ import { isFormatTokenLimitError, doFormatText, replaceTokens, + isChannelNamesMap, } from 'utils/text_formatting'; import type {ChannelNamesMap} from 'utils/text_formatting'; @@ -576,3 +579,37 @@ describe('replaceTokens', () => { } }); }); + +describe('isChannelsNameMap', () => { + it('happy path', () => { + const prop: ChannelNamesMap = { + 'some id': { + display_name: 'some name', + team_name: 'some team name', + }, + 'some other id': 'simple string', + 'without team name': {display_name: 'some other name'}, + }; + expect(isChannelNamesMap(prop)).toBe(true); + }); + + it('common false cases', () => { + expect(isChannelNamesMap('')).toBe(false); + expect(isChannelNamesMap(undefined)).toBe(false); + expect(isChannelNamesMap(true)).toBe(false); + expect(isChannelNamesMap(1)).toBe(false); + }); + + it('display names are required', () => { + const prop: DeepPartial = { + 'some id': { + display_name: 'some name', + team_name: 'some team name', + }, + }; + expect(isChannelNamesMap(prop)).toBe(true); + + delete (prop['some id'] as any).display_name; + expect(isChannelNamesMap(prop)).toBe(false); + }); +}); diff --git a/webapp/channels/src/utils/text_formatting.tsx b/webapp/channels/src/utils/text_formatting.tsx index 4d0b55ad5e..ffc7fac968 100644 --- a/webapp/channels/src/utils/text_formatting.tsx +++ b/webapp/channels/src/utils/text_formatting.tsx @@ -5,6 +5,7 @@ import emojiRegex from 'emoji-regex'; import type {Renderer} from 'marked'; import type {SystemEmoji} from '@mattermost/types/emojis'; +import {isRecordOf} from '@mattermost/types/utilities'; import type {HighlightWithoutNotificationKey} from 'mattermost-redux/selectors/entities/users'; @@ -37,6 +38,28 @@ export type ChannelNamesMap = { } | string; }; +export function isChannelNamesMap(v: unknown): v is ChannelNamesMap { + return isRecordOf(v, (e) => { + if (typeof e === 'string') { + return true; + } + + if (typeof e !== 'object' || !e) { + return false; + } + + if (!('display_name' in e) || typeof e.display_name !== 'string') { + return false; + } + + if ('team_name' in e && typeof e.team_name !== 'string') { + return false; + } + + return true; + }); +} + export type SearchPattern = { pattern: RegExp; term: string; diff --git a/webapp/platform/types/src/apps.ts b/webapp/platform/types/src/apps.ts index 900733f098..c5edd3fd64 100644 --- a/webapp/platform/types/src/apps.ts +++ b/webapp/platform/types/src/apps.ts @@ -1,7 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import type {ProductScope} from './products'; +import {isProductScope, type ProductScope} from './products'; +import {isArrayOf, isStringArray} from './utilities'; export enum Permission { UserJoinedChannelNotification = 'user_joined_channel_notification', @@ -82,6 +83,72 @@ export type AppBinding = { submit?: AppCall; }; +export function isAppBinding(obj: unknown): obj is AppBinding { + if (typeof obj !== 'object' || obj === null) { + return false; + } + + const binding = obj as AppBinding; + + if (typeof binding.app_id !== 'string' || typeof binding.label !== 'string') { + return false; + } + + if (binding.location !== undefined && typeof binding.location !== 'string') { + return false; + } + + if (binding.supported_product_ids !== undefined && !isProductScope(binding.supported_product_ids)) { + return false; + } + + if (binding.icon !== undefined && typeof binding.icon !== 'string') { + return false; + } + + if (binding.hint !== undefined && typeof binding.hint !== 'string') { + return false; + } + + if (binding.description !== undefined && typeof binding.description !== 'string') { + return false; + } + + if (binding.role_id !== undefined && typeof binding.role_id !== 'string') { + return false; + } + + if (binding.depends_on_team !== undefined && typeof binding.depends_on_team !== 'boolean') { + return false; + } + + if (binding.depends_on_channel !== undefined && typeof binding.depends_on_channel !== 'boolean') { + return false; + } + + if (binding.depends_on_user !== undefined && typeof binding.depends_on_user !== 'boolean') { + return false; + } + + if (binding.depends_on_post !== undefined && typeof binding.depends_on_post !== 'boolean') { + return false; + } + + if (binding.bindings !== undefined && !isArrayOf(binding.bindings, isAppBinding)) { + return false; + } + + if (binding.form !== undefined && !isAppForm(binding.form)) { + return false; + } + + if (binding.submit !== undefined && !isAppCall(binding.submit)) { + return false; + } + + return true; +} + export type AppCallValues = { [name: string]: any; }; @@ -92,6 +159,26 @@ export type AppCall = { state?: any; }; +function isAppCall(obj: unknown): obj is AppCall { + if (typeof obj !== 'object' || obj === null) { + return false; + } + + const call = obj as AppCall; + + if (typeof call.path !== 'string') { + return false; + } + + if (call.expand !== undefined && !isAppExpand(call.expand)) { + return false; + } + + // Here we're assuming that 'state' can be of any type, so no type check for 'state' + + return true; +} + export type AppCallRequest = AppCall & { context: AppContext; values?: AppCallValues; @@ -159,6 +246,64 @@ export type AppExpand = { locale?: AppExpandLevel; }; +function isAppExpand(v: unknown): v is AppExpand { + if (typeof v !== 'object' || v === null) { + return false; + } + + const expand = v as AppExpand; + + if (expand.app !== undefined && typeof expand.app !== 'string') { + return false; + } + + if (expand.acting_user !== undefined && typeof expand.acting_user !== 'string') { + return false; + } + + if (expand.acting_user_access_token !== undefined && typeof expand.acting_user_access_token !== 'string') { + return false; + } + + if (expand.channel !== undefined && typeof expand.channel !== 'string') { + return false; + } + + if (expand.config !== undefined && typeof expand.config !== 'string') { + return false; + } + + if (expand.mentioned !== undefined && typeof expand.mentioned !== 'string') { + return false; + } + + if (expand.parent_post !== undefined && typeof expand.parent_post !== 'string') { + return false; + } + + if (expand.post !== undefined && typeof expand.post !== 'string') { + return false; + } + + if (expand.root_post !== undefined && typeof expand.root_post !== 'string') { + return false; + } + + if (expand.team !== undefined && typeof expand.team !== 'string') { + return false; + } + + if (expand.user !== undefined && typeof expand.user !== 'string') { + return false; + } + + if (expand.locale !== undefined && typeof expand.locale !== 'string') { + return false; + } + + return true; +} + export type AppForm = { title?: string; header?: string; @@ -183,7 +328,78 @@ export type AppForm = { depends_on?: string[]; }; +function isAppForm(v: unknown): v is AppForm { + if (typeof v !== 'object' || v === null) { + return false; + } + + const form = v as AppForm; + + if (form.title !== undefined && typeof form.title !== 'string') { + return false; + } + + if (form.header !== undefined && typeof form.header !== 'string') { + return false; + } + + if (form.footer !== undefined && typeof form.footer !== 'string') { + return false; + } + + if (form.icon !== undefined && typeof form.icon !== 'string') { + return false; + } + + if (form.submit_buttons !== undefined && typeof form.submit_buttons !== 'string') { + return false; + } + + if (form.cancel_button !== undefined && typeof form.cancel_button !== 'boolean') { + return false; + } + + if (form.submit_on_cancel !== undefined && typeof form.submit_on_cancel !== 'boolean') { + return false; + } + + if (form.fields !== undefined && !isArrayOf(form.fields, isAppField)) { + return false; + } + + if (form.source !== undefined && !isAppCall(form.source)) { + return false; + } + + if (form.submit !== undefined && !isAppCall(form.submit)) { + return false; + } + + if (form.depends_on !== undefined && !isStringArray(form.depends_on)) { + return false; + } + + return true; +} + export type AppFormValue = string | AppSelectOption | boolean | null; + +function isAppFormValue(v: unknown): v is AppFormValue { + if (typeof v === 'string') { + return true; + } + + if (typeof v === 'boolean') { + return true; + } + + if (v === null) { + return true; + } + + return isAppSelectOption(v); +} + export type AppFormValues = { [name: string]: AppFormValue }; export type AppSelectOption = { @@ -192,6 +408,24 @@ export type AppSelectOption = { icon_data?: string; }; +function isAppSelectOption(v: unknown): v is AppSelectOption { + if (typeof v !== 'object' || v === null) { + return false; + } + + const option = v as AppSelectOption; + + if (typeof option.label !== 'string' || typeof option.value !== 'string') { + return false; + } + + if (option.icon_data !== undefined && typeof option.icon_data !== 'string') { + return false; + } + + return true; +} + export type AppFieldType = string; // This should go in mattermost-redux @@ -226,6 +460,80 @@ export type AppField = { max_length?: number; }; +function isAppField(v: unknown): v is AppField { + if (typeof v !== 'object' || v === null) { + return false; + } + + const field = v as AppField; + + if (typeof field.name !== 'string' || typeof field.type !== 'string') { + return false; + } + + if (field.is_required !== undefined && typeof field.is_required !== 'boolean') { + return false; + } + + if (field.readonly !== undefined && typeof field.readonly !== 'boolean') { + return false; + } + + if (field.value !== undefined && !isAppFormValue(field.value)) { + return false; + } + + if (field.description !== undefined && typeof field.description !== 'string') { + return false; + } + + if (field.label !== undefined && typeof field.label !== 'string') { + return false; + } + + if (field.hint !== undefined && typeof field.hint !== 'string') { + return false; + } + + if (field.position !== undefined && typeof field.position !== 'number') { + return false; + } + + if (field.modal_label !== undefined && typeof field.modal_label !== 'string') { + return false; + } + + if (field.refresh !== undefined && typeof field.refresh !== 'boolean') { + return false; + } + + if (field.options !== undefined && !isArrayOf(field.options, isAppSelectOption)) { + return false; + } + + if (field.multiselect !== undefined && typeof field.multiselect !== 'boolean') { + return false; + } + + if (field.lookup !== undefined && !isAppCall(field.lookup)) { + return false; + } + + if (field.subtype !== undefined && typeof field.subtype !== 'string') { + return false; + } + + if (field.min_length !== undefined && typeof field.min_length !== 'number') { + return false; + } + + if (field.max_length !== undefined && typeof field.max_length !== 'number') { + return false; + } + + return true; +} + export type AutocompleteSuggestion = { suggestion: string; complete?: string; diff --git a/webapp/platform/types/src/integration_actions.ts b/webapp/platform/types/src/integration_actions.ts index 0b5e747c56..93064b5c5b 100644 --- a/webapp/platform/types/src/integration_actions.ts +++ b/webapp/platform/types/src/integration_actions.ts @@ -1,27 +1,91 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {isArrayOf} from './utilities'; + export type PostAction = { - id?: string; + id: string; type?: string; - name?: string; + name: string; disabled?: boolean; style?: string; data_source?: string; options?: PostActionOption[]; default_option?: string; - integration?: PostActionIntegration; cookie?: string; }; +export function isPostAction(v: unknown): v is PostAction { + if (typeof v !== 'object' || !v) { + return false; + } + + if (!('id' in v)) { + return false; + } + + if (typeof v.id !== 'string') { + return false; + } + + if (!('name' in v)) { + return false; + } + + if (typeof v.name !== 'string') { + return false; + } + + if ('type' in v && typeof v.type !== 'string') { + return false; + } + + if ('disabled' in v && typeof v.disabled !== 'boolean') { + return false; + } + + if ('style' in v && typeof v.style !== 'string') { + return false; + } + + if ('data_source' in v && typeof v.data_source !== 'string') { + return false; + } + + if ('options' in v && !isArrayOf(v.options, isPostActionOption)) { + return false; + } + + if ('default_option' in v && typeof v.default_option !== 'string') { + return false; + } + + if ('cookie' in v && typeof v.cookie !== 'string') { + return false; + } + + return true; +} + export type PostActionOption = { text: string; value: string; }; -export type PostActionIntegration = { - url?: string; - context?: Record; +function isPostActionOption(v: unknown): v is PostActionOption { + if (typeof v !== 'object' || !v) { + return false; + } + + if ('text' in v && typeof v.text !== 'string') { + return false; + } + + if ('value' in v && typeof v.value !== 'string') { + return false; + } + + return true; } export type PostActionResponse = { diff --git a/webapp/platform/types/src/message_attachments.ts b/webapp/platform/types/src/message_attachments.ts index cad6ec95a2..880d9a7621 100644 --- a/webapp/platform/types/src/message_attachments.ts +++ b/webapp/platform/types/src/message_attachments.ts @@ -1,30 +1,141 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import type {PostAction} from './integration_actions'; +import {isPostAction, type PostAction} from './integration_actions'; +import {isArrayOf} from './utilities'; export type MessageAttachment = { - id: number; - fallback: string; - color: string; - pretext: string; - author_name: string; - author_link: string; - author_icon: string; - title: string; - title_link: string; - text: string; - fields: MessageAttachmentField[]; - image_url: string; - thumb_url: string; - footer: string; - footer_icon: string; - timestamp: number | string; + fallback?: string; + color?: string; + pretext?: string; + author_name?: string; + author_link?: string; + author_icon?: string; + title?: string; + title_link?: string; + text?: string; + fields?: MessageAttachmentField[] | null; + image_url?: string; + thumb_url?: string; + footer?: string; + footer_icon?: string; actions?: PostAction[]; }; +export function isMessageAttachmentArray(v: unknown): v is MessageAttachment[] { + return isArrayOf(v, isMessageAttachment); +} + +function isMessageAttachment(v: unknown): v is MessageAttachment { + if (typeof v !== 'object' || !v) { + return false; + } + + if ('fallback' in v && typeof v.fallback !== 'string') { + return false; + } + + // We may consider adding more validation to what color may be + if ('color' in v && typeof v.color !== 'string') { + return false; + } + + if ('pretext' in v && typeof v.pretext !== 'string') { + return false; + } + + if ('author_name' in v && typeof v.author_name !== 'string') { + return false; + } + + // Where it is used, we are calling isUrlSafe. We could consider calling it here + if ('author_link' in v && typeof v.author_link !== 'string') { + return false; + } + + // We may need more validation since this is going to be passed to an img src prop + if ('author_icon' in v && typeof v.author_icon !== 'string') { + return false; + } + + if ('title' in v && typeof v.title !== 'string') { + return false; + } + + // Where it is used, we are calling isUrlSafe. We could consider calling it here + if ('title_link' in v && typeof v.title_link !== 'string') { + return false; + } + + if ('text' in v && typeof v.text !== 'string') { + return false; + } + + // We may need more validation since this is going to be passed to an img src prop + if ('image_url' in v && typeof v.image_url !== 'string') { + return false; + } + + // We may need more validation since this is going to be passed to an img src prop + if ('thumb_url' in v && typeof v.thumb_url !== 'string') { + return false; + } + + // We are truncating if the size is more than some constant. We could check this here + if ('footer' in v && typeof v.footer !== 'string') { + return false; + } + + // We may need more validation since this is going to be passed to an img src prop + if ('footer_icon' in v && typeof v.footer_icon !== 'string') { + return false; + } + + if ('fields' in v && v.fields !== null && !isArrayOf(v.fields, isMessageAttachmentField)) { + return false; + } + + if ('actions' in v && !isArrayOf(v.actions, isPostAction)) { + return false; + } + + return true; +} + export type MessageAttachmentField = { title: string; value: any; - short: boolean; + short?: boolean; +} + +function isMessageAttachmentField(v: unknown) { + if (typeof v !== 'object') { + return false; + } + + if (!v) { + return false; + } + + if (!('title' in v)) { + return false; + } + + if (typeof v.title !== 'string') { + return false; + } + + if (!('value' in v)) { + return false; + } + + if (typeof v.value === 'object' && v.value && 'toString' in v.value && typeof v.value.toString !== 'function') { + return false; + } + + if ('short' in v && typeof v.short !== 'boolean') { + return false; + } + + return true; } diff --git a/webapp/platform/types/src/posts.ts b/webapp/platform/types/src/posts.ts index fd791d1950..87e01b137a 100644 --- a/webapp/platform/types/src/posts.ts +++ b/webapp/platform/types/src/posts.ts @@ -9,10 +9,10 @@ import type {FileInfo} from './files'; import type {Reaction} from './reactions'; import type {TeamType} from './teams'; import type {UserProfile} from './users'; -import type { - RelationOneToOne, - RelationOneToMany, - IDMappedObjects, +import { + type RelationOneToOne, + type RelationOneToMany, + type IDMappedObjects, } from './utilities'; export type PostType = 'system_add_remove' | @@ -86,7 +86,7 @@ export type Post = { original_id: string; message: string; type: PostType; - props: Record; + props: Record; hashtags: string; pending_post_id: string; reply_count: number; diff --git a/webapp/platform/types/src/products.ts b/webapp/platform/types/src/products.ts index f270a5ee86..2ae773e744 100644 --- a/webapp/platform/types/src/products.ts +++ b/webapp/platform/types/src/products.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {isArrayOf} from './utilities'; + /** * - `null` - explicitly Channels * - `string` - uuid - any other product @@ -9,3 +11,11 @@ export type ProductIdentifier = null | string; /** @see {@link ProductIdentifier} */ export type ProductScope = ProductIdentifier | ProductIdentifier[]; + +export function isProductScope(v: unknown): v is ProductScope { + if (v === null || typeof v === 'string') { + return true; + } + + return isArrayOf(v, (e) => e === null || typeof v === 'string'); +} diff --git a/webapp/platform/types/src/utilities.ts b/webapp/platform/types/src/utilities.ts index 749504e435..2e8a7faa5a 100644 --- a/webapp/platform/types/src/utilities.ts +++ b/webapp/platform/types/src/utilities.ts @@ -45,3 +45,31 @@ export type Intersection = Omit)>, keyof(Omit)>; export type PartialExcept, TKeysNotPartial extends keyof T> = Partial & Pick; + +export function isArrayOf(v: unknown, check: (e: unknown) => boolean): v is T[] { + if (!Array.isArray(v)) { + return false; + } + + return v.every(check); +} + +export function isStringArray(v: unknown): v is string[] { + return isArrayOf(v, (e) => typeof e === 'string'); +} + +export function isRecordOf(v: unknown, check: (e: unknown) => boolean): v is Record { + if (typeof v !== 'object' || !v) { + return false; + } + + if (!(Object.keys(v).every((k) => typeof k === 'string'))) { + return false; + } + + if (!(Object.values(v).every(check))) { + return false; + } + + return true; +}