diff --git a/app/actions/local/post.ts b/app/actions/local/post.ts index 8dffef59988..5a9b159a747 100644 --- a/app/actions/local/post.ts +++ b/app/actions/local/post.ts @@ -160,7 +160,7 @@ export async function markPostAsDeleted(serverUrl: string, post: Post, prepareRe p.message = ''; p.messageSource = ''; p.metadata = null; - p.props = undefined; + p.props = null; }); if (!prepareRecordsOnly) { diff --git a/app/actions/remote/post.ts b/app/actions/remote/post.ts index e358a094cb8..fd3193178d6 100644 --- a/app/actions/remote/post.ts +++ b/app/actions/remote/post.ts @@ -80,7 +80,7 @@ export async function createPost(serverUrl: string, post: Partial, files: const pendingPostId = post.pending_post_id || `${currentUserId}:${timestamp}`; const existing = await getPostById(database, pendingPostId); - if (existing && !existing.props.failed) { + if (existing && !existing.props?.failed) { return {data: false}; } @@ -240,7 +240,7 @@ export const retryFailedPost = async (serverUrl: string, post: PostModel) => { // timestamps will remain the same as the initial attempt for createAt // but updateAt will be use for the optimistic post UI post.prepareUpdate((p) => { - p.props = newPost.props; + p.props = newPost.props || null; p.updateAt = timestamp; }); await operator.batchRecords([post], 'retryFailedPost - first update'); diff --git a/app/actions/remote/search.ts b/app/actions/remote/search.ts index 7f99bb6e945..2a5783eacda 100644 --- a/app/actions/remote/search.ts +++ b/app/actions/remote/search.ts @@ -161,7 +161,7 @@ export const searchFiles = async (serverUrl: string, teamId: string, params: Fil }, {}); files.forEach((f) => { if (f.post_id) { - f.postProps = idToPost[f.post_id]?.props; + f.postProps = idToPost[f.post_id]?.props || {}; } }); return {files, channels}; diff --git a/app/components/files/files.tsx b/app/components/files/files.tsx index da11d676f2d..710466ea79e 100644 --- a/app/components/files/files.tsx +++ b/app/components/files/files.tsx @@ -24,7 +24,7 @@ type FilesProps = { location: string; isReplyPost: boolean; postId: string; - postProps: Record; + postProps: Record; publicLinkEnabled: boolean; } diff --git a/app/components/markdown/channel_mention/channel_mention.tsx b/app/components/markdown/channel_mention/channel_mention.tsx index 5dceb752f62..554dfbf1bf5 100644 --- a/app/components/markdown/channel_mention/channel_mention.tsx +++ b/app/components/markdown/channel_mention/channel_mention.tsx @@ -10,12 +10,38 @@ import {useServerUrl} from '@context/server'; import {t} from '@i18n'; import {alertErrorWithFallback} from '@utils/draft'; import {preventDoubleTap} from '@utils/tap'; -import {secureGetFromRecord} from '@utils/types'; +import {secureGetFromRecord, isRecordOf} from '@utils/types'; import type ChannelModel from '@typings/database/models/servers/channel'; import type TeamModel from '@typings/database/models/servers/team'; -export type ChannelMentions = Record; +export type ChannelMentions = Record; + +export function isChannelMentions(v: unknown): v is ChannelMentions { + return isRecordOf(v, (e) => { + 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; + } + + if ('id' in e && typeof e.id !== 'string') { + return false; + } + + if ('name' in e && typeof e.name !== 'string') { + return false; + } + + return true; + }); +} type ChannelMentionProps = { channelMentions?: ChannelMentions; diff --git a/app/components/markdown/channel_mention/index.ts b/app/components/markdown/channel_mention/index.ts index 6843061f79e..7e56b7f4800 100644 --- a/app/components/markdown/channel_mention/index.ts +++ b/app/components/markdown/channel_mention/index.ts @@ -12,8 +12,6 @@ import ChannelMention from './channel_mention'; import type {WithDatabaseArgs} from '@typings/database/database'; -export type ChannelMentions = Record; - const enhance = withObservables([], ({database}: WithDatabaseArgs) => { const currentTeamId = observeCurrentTeamId(database); const channels = currentTeamId.pipe( diff --git a/app/components/markdown/markdown.tsx b/app/components/markdown/markdown.tsx index 934e7cdfb01..369c435a222 100644 --- a/app/components/markdown/markdown.tsx +++ b/app/components/markdown/markdown.tsx @@ -17,7 +17,7 @@ import {typography} from '@utils/typography'; import {getScheme} from '@utils/url'; import AtMention from './at_mention'; -import ChannelMention, {type ChannelMentions} from './channel_mention'; +import ChannelMention from './channel_mention'; import Hashtag from './hashtag'; import MarkdownBlockQuote from './markdown_block_quote'; import MarkdownCodeBlock from './markdown_code_block'; @@ -33,6 +33,7 @@ import MarkdownTableImage from './markdown_table_image'; import MarkdownTableRow, {type MarkdownTableRowProps} from './markdown_table_row'; import {addListItemIndices, combineTextNodes, highlightMentions, highlightWithoutNotification, highlightSearchPatterns, parseTaskLists, pullOutImages} from './transform'; +import type {ChannelMentions} from './channel_mention/channel_mention'; import type { MarkdownAtMentionRenderer, MarkdownBaseRenderer, MarkdownBlockStyles, MarkdownChannelMentionRenderer, MarkdownEmojiRenderer, MarkdownImageRenderer, MarkdownLatexRenderer, MarkdownTextStyles, SearchPattern, UserMentionKey, HighlightWithoutNotificationKey, diff --git a/app/components/post_list/combined_user_activity/combined_user_activity.tsx b/app/components/post_list/combined_user_activity/combined_user_activity.tsx index eafea6dd27b..c176b02905f 100644 --- a/app/components/post_list/combined_user_activity/combined_user_activity.tsx +++ b/app/components/post_list/combined_user_activity/combined_user_activity.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect} from 'react'; +import React, {useCallback, useEffect, useMemo} from 'react'; import {useIntl} from 'react-intl'; import {Keyboard, type StyleProp, TouchableHighlight, View, type ViewStyle} from 'react-native'; @@ -15,6 +15,7 @@ import {useIsTablet} from '@hooks/device'; import {bottomSheetModalOptions, showModal, showModalOverCurrentContext} from '@screens/navigation'; import {emptyFunction} from '@utils/general'; import {getMarkdownTextStyles} from '@utils/markdown'; +import {isUserActivityProp} from '@utils/post_list'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {secureGetFromRecord} from '@utils/types'; @@ -71,6 +72,13 @@ const CombinedUserActivity = ({ const content = []; const removedUserIds: string[] = []; + const userActivity = useMemo(() => { + if (isUserActivityProp(post?.props?.user_activity)) { + return post?.props?.user_activity; + } + return undefined; + }, [post?.props?.user_activity]); + const getUsernames = (userIds: string[]) => { const someone = intl.formatMessage({id: 'channel_loader.someone', defaultMessage: 'Someone'}); const you = intl.formatMessage({id: 'combined_system_message.you', defaultMessage: 'You'}); @@ -170,11 +178,12 @@ const CombinedUserActivity = ({ }; useEffect(() => { - if (!post) { + if (!userActivity) { return; } - const {allUserIds, allUsernames} = post.props.user_activity; + const allUserIds = userActivity.allUserIds; + const allUsernames = userActivity.allUsernames; if (allUserIds.length) { fetchMissingProfilesByIds(serverUrl, allUserIds); } @@ -182,14 +191,14 @@ const CombinedUserActivity = ({ if (allUsernames.length) { fetchMissingProfilesByUsernames(serverUrl, allUsernames); } - }, [post?.props.user_activity.allUserIds, post?.props.user_activity.allUsernames]); + }, [userActivity?.allUserIds, userActivity?.allUsernames]); if (!post) { return null; } const itemTestID = `${testID}.${post.id}`; - const {messageData} = post.props.user_activity; + const messageData = userActivity?.messageData || []; for (const message of messageData) { const {postType, actorId} = message; const userIds = new Set(message.userIds); diff --git a/app/components/post_list/combined_user_activity/index.ts b/app/components/post_list/combined_user_activity/index.ts index 5ad70b51367..de0b5b6e703 100644 --- a/app/components/post_list/combined_user_activity/index.ts +++ b/app/components/post_list/combined_user_activity/index.ts @@ -11,7 +11,7 @@ import {queryPostsById} from '@queries/servers/post'; import {observePermissionForPost} from '@queries/servers/role'; import {observeCurrentUserId} from '@queries/servers/system'; import {observeUser, queryUsersByIdsOrUsernames} from '@queries/servers/user'; -import {generateCombinedPost, getPostIdsForCombinedUserActivityPost} from '@utils/post_list'; +import {generateCombinedPost, getPostIdsForCombinedUserActivityPost, isUserActivityProp} from '@utils/post_list'; import CombinedUserActivity from './combined_user_activity'; @@ -35,10 +35,11 @@ const withCombinedPosts = withObservables(['postId'], ({database, postId}: WithD const usernamesById = post.pipe( switchMap( (p) => { - if (!p) { + const userActivity = isUserActivityProp(p?.props?.user_activity) ? p.props.user_activity : undefined; + if (!userActivity) { return of$>({}); } - return queryUsersByIdsOrUsernames(database, p.props.user_activity.allUserIds, p.props.user_activity.allUsernames).observeWithColumns(['username']). + return queryUsersByIdsOrUsernames(database, userActivity.allUserIds, userActivity.allUsernames).observeWithColumns(['username']). pipe( // eslint-disable-next-line max-nested-callbacks switchMap((users) => { diff --git a/app/components/post_list/post/avatar/avatar.tsx b/app/components/post_list/post/avatar/avatar.tsx index 22e56729550..ac8f8aa4cbe 100644 --- a/app/components/post_list/post/avatar/avatar.tsx +++ b/app/components/post_list/post/avatar/avatar.tsx @@ -14,6 +14,7 @@ import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {openAsBottomSheet} from '@screens/navigation'; import {preventDoubleTap} from '@utils/tap'; +import {ensureString} from '@utils/types'; import type PostModel from '@typings/database/models/servers/post'; import type UserModel from '@typings/database/models/servers/user'; @@ -39,12 +40,15 @@ const Avatar = ({author, enablePostIconOverride, isAutoReponse, location, post}: const fromWebHook = post.props?.from_webhook === 'true'; const iconOverride = enablePostIconOverride && post.props?.use_user_icon !== 'true'; + const propsIconUrl = ensureString(post.props?.override_icon_url); + const propsUsername = ensureString(post.props?.override_username); + if (fromWebHook && iconOverride) { const isEmoji = Boolean(post.props?.override_icon_emoji); const frameSize = ViewConstant.PROFILE_PICTURE_SIZE; const pictureSize = isEmoji ? ViewConstant.PROFILE_PICTURE_EMOJI_SIZE : ViewConstant.PROFILE_PICTURE_SIZE; const borderRadius = isEmoji ? 0 : ViewConstant.PROFILE_PICTURE_SIZE / 2; - const overrideIconUrl = buildAbsoluteUrl(serverUrl, post.props?.override_icon_url); + const overrideIconUrl = buildAbsoluteUrl(serverUrl, propsIconUrl); let iconComponent: ReactNode; if (overrideIconUrl) { @@ -95,8 +99,8 @@ const Avatar = ({author, enablePostIconOverride, isAutoReponse, location, post}: userId: author.id, channelId: post.channelId, location, - userIconOverride: post.props?.override_username, - usernameOverride: post.props?.override_icon_url, + userIconOverride: propsIconUrl, + usernameOverride: propsUsername, }; Keyboard.dismiss(); diff --git a/app/components/post_list/post/body/add_members/add_members.tsx b/app/components/post_list/post/body/add_members/add_members.tsx index 0217af3e830..c9a0247b287 100644 --- a/app/components/post_list/post/body/add_members/add_members.tsx +++ b/app/components/post_list/post/body/add_members/add_members.tsx @@ -14,6 +14,7 @@ import {useServerUrl} from '@context/server'; import {t} from '@i18n'; import {getMarkdownTextStyles} from '@utils/markdown'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {isStringArray} from '@utils/types'; import type PostModel from '@typings/database/models/servers/post'; import type UserModel from '@typings/database/models/servers/user'; @@ -26,6 +27,39 @@ type AddMembersProps = { theme: Theme; } +export type AddMemberPostProps = { + post_id: string; + not_in_channel_user_ids?: string[]; + not_in_groups_usernames?: string[]; + not_in_channel_usernames?: string[]; + user_ids?: string[]; + usernames?: string[]; +} + +export function isAddMemberProps(v: unknown): v is AddMemberPostProps { + if (typeof v !== 'object' || !v) { + return false; + } + + if (!('post_id' in v) || typeof v.post_id !== 'string') { + return false; + } + + if (('not_in_channel_user_ids' in v) && !isStringArray(v.not_in_channel_user_ids)) { + return false; + } + + if (('not_in_groups_usernames' in v) && !isStringArray(v.not_in_groups_usernames)) { + return false; + } + + if (('not_in_channel_usernames' in v) && !isStringArray(v.not_in_channel_usernames)) { + return false; + } + + return true; +} + const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { message: { @@ -41,20 +75,17 @@ const AddMembers = ({channelType, currentUser, location, post, theme}: AddMember const styles = getStyleSheet(theme); const textStyles = getMarkdownTextStyles(theme); const serverUrl = useServerUrl(); - const postId: string = post.props.add_channel_member?.post_id; - const noGroupsUsernames = post.props.add_channel_member?.not_in_groups_usernames; - let userIds: string[] = post.props.add_channel_member?.not_in_channel_user_ids; - let usernames: string[] = post.props.add_channel_member?.not_in_channel_usernames; - - if (!postId || !channelType) { + if (!isAddMemberProps(post.props?.add_channel_member)) { return null; } - if (!userIds) { - userIds = post.props.add_channel_member?.user_ids; - } - if (!usernames) { - usernames = post.props.add_channel_member?.usernames; + const postId = post.props.add_channel_member.post_id; + const noGroupsUsernames = post.props.add_channel_member.not_in_groups_usernames || []; + const userIds = post.props.add_channel_member.not_in_channel_user_ids || post.props.add_channel_member.user_ids || []; + const usernames = post.props.add_channel_member.not_in_channel_usernames || post.props.add_channel_member?.usernames || []; + + if (!postId || !channelType) { + return null; } const handleAddChannelMember = () => { diff --git a/app/components/post_list/post/body/content/embedded_bindings/button_binding/index.tsx b/app/components/post_list/post/body/content/embedded_bindings/button_binding/index.tsx index b4b6fbc4d38..dafa0671e4d 100644 --- a/app/components/post_list/post/body/content/embedded_bindings/button_binding/index.tsx +++ b/app/components/post_list/post/body/content/embedded_bindings/button_binding/index.tsx @@ -15,7 +15,7 @@ import {observeChannel} from '@queries/servers/channel'; import {observeCurrentTeamId} from '@queries/servers/system'; import {showAppForm} from '@screens/navigation'; import {createCallContext} from '@utils/apps'; -import {getStatusColors} from '@utils/message_attachment_colors'; +import {getStatusColors} from '@utils/message_attachment'; import {preventDoubleTap} from '@utils/tap'; import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; diff --git a/app/components/post_list/post/body/content/embedded_bindings/index.tsx b/app/components/post_list/post/body/content/embedded_bindings/index.tsx index 3c6d1a3858c..afad7f7d2d2 100644 --- a/app/components/post_list/post/body/content/embedded_bindings/index.tsx +++ b/app/components/post_list/post/body/content/embedded_bindings/index.tsx @@ -4,6 +4,9 @@ import React from 'react'; import {View} from 'react-native'; +import {isAppBinding, validateBindings} from '@utils/apps'; +import {isArrayOf} from '@utils/types'; + import EmbeddedBinding from './embedded_binding'; import type PostModel from '@typings/database/models/servers/post'; @@ -16,7 +19,7 @@ type Props = { const EmbeddedBindings = ({location, post, theme}: Props) => { const content: React.ReactNode[] = []; - const embeds: AppBinding[] = post.props.app_bindings; + const embeds: AppBinding[] = isArrayOf(post.props?.app_bindings, isAppBinding) ? validateBindings(post.props.app_bindings) : []; embeds.forEach((embed, i) => { content.push( diff --git a/app/components/post_list/post/body/content/index.tsx b/app/components/post_list/post/body/content/index.tsx index 38ae3b79b18..abf34b557ab 100644 --- a/app/components/post_list/post/body/content/index.tsx +++ b/app/components/post_list/post/body/content/index.tsx @@ -3,6 +3,7 @@ import React from 'react'; +import {isMessageAttachmentArray} from '@utils/message_attachment'; import {isYoutubeLink} from '@utils/url'; import EmbeddedBindings from './embedded_bindings'; @@ -31,7 +32,9 @@ const contentType: Record = { const Content = ({isReplyPost, layoutWidth, location, post, theme}: ContentProps) => { let type: string | undefined = post.metadata?.embeds?.[0].type; - if (!type && post.props?.app_bindings?.length) { + + const nAppBindings = Array.isArray(post.props?.app_bindings) ? post.props.app_bindings.length : 0; + if (!type && nAppBindings) { type = contentType.app_bindings; } @@ -39,6 +42,8 @@ const Content = ({isReplyPost, layoutWidth, location, post, theme}: ContentProps return null; } + const attachments = isMessageAttachmentArray(post.props?.attachments) ? post.props.attachments : []; + switch (contentType[type]) { case contentType.image: return ( @@ -74,10 +79,10 @@ const Content = ({isReplyPost, layoutWidth, location, post, theme}: ContentProps /> ); case contentType.message_attachment: - if (post.props.attachments?.length) { + if (attachments.length) { return ( |undefined => { if (!isReplyPost || (isCRTEnabled && location === Screens.PERMALINK)) { diff --git a/app/components/post_list/post/body/message/message.tsx b/app/components/post_list/post/body/message/message.tsx index f15cfb2c1a9..d8101ce8d34 100644 --- a/app/components/post_list/post/body/message/message.tsx +++ b/app/components/post_list/post/body/message/message.tsx @@ -1,11 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useState} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import {type LayoutChangeEvent, ScrollView, useWindowDimensions, View} from 'react-native'; import Animated from 'react-native-reanimated'; import Markdown from '@components/markdown'; +import {isChannelMentions} from '@components/markdown/channel_mention/channel_mention'; import {SEARCH} from '@constants/screens'; import {useShowMoreAnimatedStyle} from '@hooks/show_more'; import {getMarkdownTextStyles, getMarkdownBlockStyles} from '@utils/markdown'; @@ -69,6 +70,10 @@ const Message = ({currentUser, isHighlightWithoutNotificationLicensed, highlight const onLayout = useCallback((event: LayoutChangeEvent) => setHeight(event.nativeEvent.layout.height), []); const onPress = () => setOpen(!open); + const channelMentions = useMemo(() => { + return isChannelMentions(post.props?.channel_mentions) ? post.props.channel_mentions : {}; + }, [post.props?.channel_mentions]); + return ( <> @@ -86,7 +91,7 @@ const Message = ({currentUser, isHighlightWithoutNotificationLicensed, highlight baseTextStyle={style.message} blockStyles={blockStyles} channelId={post.channelId} - channelMentions={post.props?.channel_mentions} + channelMentions={channelMentions} imagesMetadata={post.metadata?.images} isEdited={isEdited} isReplyPost={isReplyPost} @@ -100,7 +105,7 @@ const Message = ({currentUser, isHighlightWithoutNotificationLicensed, highlight highlightKeys={isHighlightWithoutNotificationLicensed ? (currentUser?.highlightKeys ?? EMPTY_HIGHLIGHT_KEYS) : EMPTY_HIGHLIGHT_KEYS} searchPatterns={searchPatterns} theme={theme} - isUnsafeLinksPost={post.props.unsafe_links && post.props.unsafe_links !== ''} + isUnsafeLinksPost={Boolean(post.props?.unsafe_links && post.props.unsafe_links !== '')} /> diff --git a/app/components/post_list/post/header/header.tsx b/app/components/post_list/post/header/header.tsx index f79583e8dc9..76649cb34f6 100644 --- a/app/components/post_list/post/header/header.tsx +++ b/app/components/post_list/post/header/header.tsx @@ -12,6 +12,7 @@ import {useTheme} from '@context/theme'; import {DEFAULT_LOCALE} from '@i18n'; import {postUserDisplayName} from '@utils/post'; import {makeStyleSheetFromTheme} from '@utils/theme'; +import {ensureString} from '@utils/types'; import {typography} from '@utils/typography'; import {displayUsername, getUserCustomStatus, getUserTimezone, isCustomStatusExpired} from '@utils/user'; @@ -96,6 +97,8 @@ const Header = (props: HeaderProps) => { isCustomStatusEnabled && displayName && customStatus && !(isSystemPost || author?.isBot || isAutoResponse || isWebHook), ) && !isCustomStatusExpired(author) && Boolean(customStatus?.emoji); + const userIconOverride = ensureString(post.props?.override_icon_url); + const usernameOverride = ensureString(post.props?.override_username); return ( <> @@ -109,9 +112,9 @@ const Header = (props: HeaderProps) => { rootPostAuthor={rootAuthorDisplayName} shouldRenderReplyButton={shouldRenderReplyButton} theme={theme} - userIconOverride={post.props?.override_icon_url} + userIconOverride={userIconOverride} userId={post.userId} - usernameOverride={post.props?.override_username} + usernameOverride={usernameOverride} showCustomStatusEmoji={showCustomStatusEmoji} customStatus={customStatus!} /> diff --git a/app/components/post_list/post/system_message/system_message.tsx b/app/components/post_list/post/system_message/system_message.tsx index f7c88540452..9339b619793 100644 --- a/app/components/post_list/post/system_message/system_message.tsx +++ b/app/components/post_list/post/system_message/system_message.tsx @@ -12,7 +12,7 @@ import {useTheme} from '@context/theme'; import {t} from '@i18n'; import {getMarkdownTextStyles} from '@utils/markdown'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; -import {secureGetFromRecord} from '@utils/types'; +import {secureGetFromRecord, ensureString} from '@utils/types'; import {typography} from '@utils/typography'; import type PostModel from '@typings/database/models/servers/post'; @@ -101,8 +101,8 @@ const renderHeaderChangeMessage = ({post, author, location, styles, intl, theme} } const username = renderUsername(author.username); - const oldHeader = post.props?.old_header; - const newHeader = post.props?.new_header; + const oldHeader = ensureString(post.props?.old_header); + const newHeader = ensureString(post.props?.new_header); let localeHolder; if (post.props?.new_header) { @@ -144,12 +144,12 @@ const renderPurposeChangeMessage = ({post, author, location, styles, intl, theme } const username = renderUsername(author.username); - const oldPurpose = post.props?.old_purpose; - const newPurpose = post.props?.new_purpose; + const oldPurpose = ensureString(post.props?.old_purpose); + const newPurpose = ensureString(post.props?.new_purpose); let localeHolder; - if (post.props?.new_purpose) { - if (post.props?.old_purpose) { + if (newPurpose) { + if (oldPurpose) { localeHolder = { id: t('mobile.system_message.update_channel_purpose_message.updated_from'), defaultMessage: '{username} updated the channel purpose from: {oldPurpose} to: {newPurpose}', @@ -166,7 +166,7 @@ const renderPurposeChangeMessage = ({post, author, location, styles, intl, theme values = {username, oldPurpose, newPurpose}; return renderMessage({post, styles, intl, location, localeHolder, values, skipMarkdown: true, theme}); - } else if (post.props?.old_purpose) { + } else if (oldPurpose) { localeHolder = { id: t('mobile.system_message.update_channel_purpose_message.removed'), defaultMessage: '{username} removed the channel purpose (was: {oldPurpose})', @@ -180,8 +180,8 @@ const renderPurposeChangeMessage = ({post, author, location, styles, intl, theme }; const renderDisplayNameChangeMessage = ({post, author, location, styles, intl, theme}: RenderersProps) => { - const oldDisplayName = post.props?.old_displayname; - const newDisplayName = post.props?.new_displayname; + const oldDisplayName = ensureString(post.props?.old_displayname); + const newDisplayName = ensureString(post.props?.new_displayname); if (!(author?.username)) { return null; @@ -224,13 +224,13 @@ const renderUnarchivedMessage = ({post, author, location, styles, intl, theme}: }; const renderAddGuestToChannelMessage = ({post, location, styles, intl, theme}: RenderersProps, hideGuestTags: boolean) => { - if (!post.props.username || !post.props.addedUsername) { + const username = renderUsername(ensureString(post.props?.username)); + const addedUsername = renderUsername(ensureString(post.props?.addedUsername)); + + if (!username || !addedUsername) { return null; } - const username = renderUsername(post.props.username); - const addedUsername = renderUsername(post.props.addedUsername); - const localeHolder = hideGuestTags ? postTypeMessages[Post.POST_TYPES.ADD_TO_CHANNEL].one : { id: t('api.channel.add_guest.added'), defaultMessage: '{addedUsername} added to the channel as a guest by {username}.', @@ -241,11 +241,11 @@ const renderAddGuestToChannelMessage = ({post, location, styles, intl, theme}: R }; const renderGuestJoinChannelMessage = ({post, styles, location, intl, theme}: RenderersProps, hideGuestTags: boolean) => { - if (!post.props.username) { + const username = renderUsername(ensureString(post.props?.username)); + if (!username) { return null; } - const username = renderUsername(post.props.username); const localeHolder = hideGuestTags ? postTypeMessages[Post.POST_TYPES.JOIN_CHANNEL].one : { id: t('api.channel.guest_join_channel.post_and_forget'), defaultMessage: '{username} joined the channel as a guest.', diff --git a/app/helpers/api/user.ts b/app/helpers/api/user.ts index f0576f5841f..bccc6bf5ec9 100644 --- a/app/helpers/api/user.ts +++ b/app/helpers/api/user.ts @@ -3,6 +3,7 @@ import {General} from '@constants'; import {MENTIONS_REGEX} from '@constants/autocomplete'; +import {isMessageAttachmentArray} from '@utils/message_attachment'; export const getNeededAtMentionedUsernames = (usernames: Set, posts: Post[], excludeUsername?: string) => { const usernamesToLoad = new Set(); @@ -36,8 +37,9 @@ export const getNeededAtMentionedUsernames = (usernames: Set, posts: Pos // These correspond to the fields searched by getMentionsEnabledFields on the server findNeededUsernames(post.message); - if (post.props?.attachments) { - for (const attachment of post.props.attachments) { + const attachments = isMessageAttachmentArray(post.props?.attachments) ? post.props.attachments : undefined; + if (attachments) { + for (const attachment of attachments) { findNeededUsernames(attachment.pretext); findNeededUsernames(attachment.text); } diff --git a/app/products/calls/components/calls_custom_message/index.ts b/app/products/calls/components/calls_custom_message/index.ts index 559a8e98197..a5cd48febd5 100644 --- a/app/products/calls/components/calls_custom_message/index.ts +++ b/app/products/calls/components/calls_custom_message/index.ts @@ -30,7 +30,7 @@ const enhanced = withObservables(['post'], ({serverUrl, post, database}: OwnProp ); // The call is not active, so return early with what we need to render the post. - if (post.props.end_at) { + if (post.props?.end_at) { return { currentUser, isMilitaryTime, diff --git a/app/products/calls/utils.test.ts b/app/products/calls/utils.test.ts index cf59dc1c364..c9465fa4639 100644 --- a/app/products/calls/utils.test.ts +++ b/app/products/calls/utils.test.ts @@ -194,12 +194,12 @@ describe('getCallPropsFromPost', () => { const props = getCallPropsFromPost(post); - expect(props.title).toBe(post.props.title); - expect(props.start_at).toBe(post.props.start_at); - expect(props.end_at).toBe(post.props.end_at); - expect(props.recordings).toBe(post.props.recordings); - expect(props.recording_files).toBe(post.props.recording_files); - expect(props.transcriptions).toBe(post.props.transcriptions); - expect(props.participants).toBe(post.props.participants); + expect(props.title).toBe(post.props?.title); + expect(props.start_at).toBe(post.props?.start_at); + expect(props.end_at).toBe(post.props?.end_at); + expect(props.recordings).toBe(post.props?.recordings); + expect(props.recording_files).toBe(post.props?.recording_files); + expect(props.transcriptions).toBe(post.props?.transcriptions); + expect(props.participants).toBe(post.props?.participants); }); }); diff --git a/app/products/calls/utils.ts b/app/products/calls/utils.ts index 13969feb07b..2b1917d1325 100644 --- a/app/products/calls/utils.ts +++ b/app/products/calls/utils.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import {makeCallsBaseAndBadgeRGB, rgbToCSS} from '@mattermost/calls'; +import {type CallsConfig, type CallPostProps, isCaption, type Caption, isCallJobMetadata, type CallJobMetadata} from '@mattermost/calls/lib/types'; import {Alert} from 'react-native'; import {SelectedTrackType, TextTrackType, type ISO639_1, type SelectedTrack, type TextTracks} from 'react-native-video'; @@ -9,6 +10,7 @@ import {buildFileUrl} from '@actions/remote/file'; import {Calls, Post} from '@constants'; import {NOTIFICATION_SUB_TYPE} from '@constants/push_notification'; import {isMinimumServerVersion} from '@utils/helpers'; +import {ensureNumber, ensureString, isArrayOf, isRecordOf, isStringArray} from '@utils/types'; import {displayUsername} from '@utils/user'; import type { @@ -17,7 +19,6 @@ import type { CallsTheme, CallsVersion, } from '@calls/types/calls'; -import type {CallsConfig, Caption, CallPostProps} from '@mattermost/calls/lib/types'; import type PostModel from '@typings/database/models/servers/post'; import type UserModel from '@typings/database/models/servers/user'; import type {IntlShape} from 'react-intl'; @@ -217,17 +218,17 @@ export function isCallsStartedMessage(payload?: NotificationData) { return (payload?.message === 'You\'ve been invited to a call' || callsMessageRegex.test(payload?.message || '')); } -export const hasCaptions = (postProps?: Record & { captions?: Caption[] }): boolean => { - return !(!postProps || !postProps.captions?.[0]); +export const hasCaptions = (postProps?: Record): boolean => { + return Boolean(isArrayOf(postProps?.captions, isCaption) && postProps.captions[0]); }; -export const getTranscriptionUri = (serverUrl: string, postProps?: Record & { captions?: Caption[] }): { +export const getTranscriptionUri = (serverUrl: string, postProps?: Record): { tracks?: TextTracks; selected: SelectedTrack; } => { // Note: We're not using hasCaptions above because this tells typescript that the caption exists later. // We could use some fancy typescript to do the same, but it's not worth the complexity. - if (!postProps || !postProps.captions?.[0]) { + if (!isArrayOf(postProps?.captions, isCaption) || !postProps.captions[0]) { return { tracks: undefined, selected: {type: SelectedTrackType.DISABLED, value: ''}, @@ -247,20 +248,16 @@ export const getTranscriptionUri = (serverUrl: string, postProps?: Record(post.props?.recordings, isCallJobMetadata) ? post.props.recordings : {}, + transcriptions: isRecordOf(post.props?.transcriptions, isCallJobMetadata) ? post.props.transcriptions : {}, + participants: isStringArray(post.props?.participants) ? post.props.participants : [], // DEPRECATED - recording_files: Array.isArray(post.props?.recording_files) ? post.props.recording_files : [], + recording_files: isStringArray(post.props?.recording_files) ? post.props.recording_files : [], }; } diff --git a/app/screens/gallery/footer/footer.tsx b/app/screens/gallery/footer/footer.tsx index 9969468a8c7..a12b0e0c18f 100644 --- a/app/screens/gallery/footer/footer.tsx +++ b/app/screens/gallery/footer/footer.tsx @@ -9,6 +9,7 @@ import {SafeAreaView, type Edge, useSafeAreaInsets} from 'react-native-safe-area import {Events} from '@constants'; import {GALLERY_FOOTER_HEIGHT} from '@constants/gallery'; import {changeOpacity} from '@utils/theme'; +import {ensureString} from '@utils/types'; import {displayUsername} from '@utils/user'; import Actions from './actions'; @@ -71,14 +72,14 @@ const Footer = ({ let overrideIconUrl; if (enablePostIconOverride && post?.props?.use_user_icon !== 'true' && post?.props?.override_icon_url) { - overrideIconUrl = post.props.override_icon_url; + overrideIconUrl = ensureString(post.props.override_icon_url); } let userDisplayName; if (item.type === 'avatar') { userDisplayName = item.name; } else if (enablePostUsernameOverride && post?.props?.override_username) { - userDisplayName = post.props.override_username as string; + userDisplayName = ensureString(post.props.override_username); } else { userDisplayName = displayUsername(author, undefined, teammateNameDisplay); } diff --git a/app/utils/apps.ts b/app/utils/apps.ts index bd21068c690..0f2fb2426db 100644 --- a/app/utils/apps.ts +++ b/app/utils/apps.ts @@ -4,6 +4,7 @@ import {AppBindingLocations, AppCallResponseTypes, AppFieldTypes} from '@constants/apps'; import {generateId} from './general'; +import {isArrayOf, isStringArray} from './types'; export function cleanBinding(binding: AppBinding, topLocation: string): AppBinding|null { return cleanBindingRec(binding, topLocation, 0); @@ -253,3 +254,297 @@ export const makeCallErrorResponse = (errMessage: string): AppCallResponse }; export const filterEmptyOptions = (option: AppSelectOption): boolean => Boolean(option.value && !option.value.match(/^[ \t]+$/)); + +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.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; + } + + return true; +} + +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; +} + +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); +} + +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; +} + +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; +} + +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 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.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; +} diff --git a/app/utils/gallery/index.ts b/app/utils/gallery/index.ts index 80148f71e9c..ba177001ceb 100644 --- a/app/utils/gallery/index.ts +++ b/app/utils/gallery/index.ts @@ -29,7 +29,7 @@ export const clampVelocity = (velocity: number, minVelocity: number, maxVelocity return Math.max(Math.min(velocity, -minVelocity), -maxVelocity); }; -export const fileToGalleryItem = (file: FileInfo, authorId?: string, postProps?: Record, lastPictureUpdate = 0): GalleryItemType => { +export const fileToGalleryItem = (file: FileInfo, authorId?: string, postProps?: Record, lastPictureUpdate = 0): GalleryItemType => { let type: GalleryItemType['type'] = 'file'; if (isVideo(file)) { type = 'video'; diff --git a/app/utils/message_attachment.ts b/app/utils/message_attachment.ts new file mode 100644 index 00000000000..10ec5ff50c4 --- /dev/null +++ b/app/utils/message_attachment.ts @@ -0,0 +1,195 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {isArrayOf} from './types'; + +export function getStatusColors(theme: Theme): Dictionary { + return { + good: '#00c100', + warning: '#dede01', + danger: theme.errorTextColor, + default: theme.centerChannelColor, + primary: theme.buttonBg, + success: theme.onlineIndicator, + }; +} + +export function isMessageAttachmentArray(v: unknown): v is MessageAttachment[] { + return isArrayOf(v, isMessageAttachment); +} + +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; +} + +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; +} + +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; +} + +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; +} diff --git a/app/utils/message_attachment_colors.test.ts b/app/utils/message_attachment_colors.test.ts index 3ee07e98404..bea0b023342 100644 --- a/app/utils/message_attachment_colors.test.ts +++ b/app/utils/message_attachment_colors.test.ts @@ -3,7 +3,7 @@ import {Preferences} from '@constants'; -import {getStatusColors} from './message_attachment_colors'; +import {getStatusColors} from './message_attachment'; describe('getStatusColors', () => { const mockTheme = Preferences.THEMES.denim; diff --git a/app/utils/message_attachment_colors.ts b/app/utils/message_attachment_colors.ts deleted file mode 100644 index 210e02d0893..00000000000 --- a/app/utils/message_attachment_colors.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -export function getStatusColors(theme: Theme): Dictionary { - return { - good: '#00c100', - warning: '#dede01', - danger: theme.errorTextColor, - default: theme.centerChannelColor, - primary: theme.buttonBg, - success: theme.onlineIndicator, - }; -} diff --git a/app/utils/post/index.test.ts b/app/utils/post/index.test.ts index 463577f2bd4..b6620ea2b8f 100644 --- a/app/utils/post/index.test.ts +++ b/app/utils/post/index.test.ts @@ -85,7 +85,7 @@ describe('post utils', () => { const post = { props: { from_webhook: 'true', - }, + } as Record, } as PostModel; const result = isFromWebhook(post); @@ -96,7 +96,7 @@ describe('post utils', () => { const post = { props: { from_webhook: 'false', - }, + } as Record, } as PostModel; const result = isFromWebhook(post); @@ -149,7 +149,7 @@ describe('post utils', () => { const post = { props: { failed: true, - }, + } as Record, pendingPostId: 'id', id: 'id', updateAt: Date.now() - Post.POST_TIME_TO_FAIL - 1000, @@ -471,7 +471,7 @@ describe('post utils', () => { props: { from_webhook: 'true', override_username: 'webhook_user', - }, + } as Record, } as PostModel; const result = postUserDisplayName(post, undefined, undefined, true); @@ -482,7 +482,7 @@ describe('post utils', () => { const post = { props: { from_webhook: 'false', - }, + } as Record, } as PostModel; const author = { username: 'user1', @@ -497,7 +497,7 @@ describe('post utils', () => { const post = { props: { from_webhook: 'false', - }, + } as Record, } as PostModel; const author = { username: 'user1', diff --git a/app/utils/post/index.ts b/app/utils/post/index.ts index 7cceeed5f08..dd29cdd5c6e 100644 --- a/app/utils/post/index.ts +++ b/app/utils/post/index.ts @@ -11,6 +11,7 @@ import DatabaseManager from '@database/manager'; import {DEFAULT_LOCALE} from '@i18n'; import {getUserById} from '@queries/servers/user'; import {toMilliseconds} from '@utils/datetime'; +import {ensureString} from '@utils/types'; import {displayUsername, getUserIdFromChannelName} from '@utils/user'; import type PostModel from '@typings/database/models/servers/post'; @@ -36,7 +37,7 @@ export function areConsecutivePosts(post: PostModel, previousPost: PostModel) { } export function isFromWebhook(post: PostModel | Post): boolean { - return post.props && post.props.from_webhook === 'true'; + return post.props?.from_webhook === 'true'; } export function isEdited(post: PostModel): boolean { @@ -48,7 +49,7 @@ export function isPostEphemeral(post: PostModel): boolean { } export function isPostFailed(post: PostModel): boolean { - return post.props?.failed || ((post.pendingPostId === post.id) && (Date.now() > post.updateAt + POST_TIME_TO_FAIL)); + return Boolean(post.props?.failed) || ((post.pendingPostId === post.id) && (Date.now() > post.updateAt + POST_TIME_TO_FAIL)); } export function isPostPendingOrFailed(post: PostModel): boolean { @@ -64,8 +65,13 @@ export function fromAutoResponder(post: PostModel): boolean { } export function postUserDisplayName(post: PostModel, author?: UserModel, teammateNameDisplay?: string, enablePostUsernameOverride = false) { - if (isFromWebhook(post) && post.props?.override_username && enablePostUsernameOverride) { - return post.props.override_username; + const overrideUsername = ensureString(post.props?.override_username); + if ( + isFromWebhook(post) && + enablePostUsernameOverride && + overrideUsername + ) { + return overrideUsername; } return displayUsername(author, author?.locale || DEFAULT_LOCALE, teammateNameDisplay, true); diff --git a/app/utils/post_list/index.test.ts b/app/utils/post_list/index.test.ts index 29432aafb81..64cff6488a1 100644 --- a/app/utils/post_list/index.test.ts +++ b/app/utils/post_list/index.test.ts @@ -319,7 +319,7 @@ describe('generateCombinedPost', () => { const result = generateCombinedPost('combined-post-id', systemPosts); // Ensure all post ids are included in the system_post_ids prop - expect(result.props.system_post_ids).toEqual(['post1', 'post2', 'post3']); + expect(result.props?.system_post_ids).toEqual(['post1', 'post2', 'post3']); }); it('should include combined messages in the props object', () => { @@ -331,7 +331,7 @@ describe('generateCombinedPost', () => { const result = generateCombinedPost('combined-post-id', systemPosts); // Ensure the messages prop includes all individual messages - expect(result.props.messages).toEqual(['Message 1', 'Message 2']); + expect(result.props?.messages).toEqual(['Message 1', 'Message 2']); }); it('should set the post type to COMBINED_USER_ACTIVITY', () => { @@ -387,7 +387,7 @@ describe('generateCombinedPost', () => { const result = generateCombinedPost(combinedPostId, systemPosts); // Extract the post types from the sorted messages in the combined post props - const sortedPostTypes = result.props.user_activity_posts.map((post: PostModel) => post.type); + const sortedPostTypes = (result.props?.user_activity_posts as any).map((post: PostModel) => post.type); // Expect the post types to be sorted based on their priorities in comparePostTypes expect(sortedPostTypes).toEqual([ @@ -422,9 +422,9 @@ describe('generateCombinedPost', () => { const result = generateCombinedPost(combinedPostId, systemPosts); // Ensure user activities are combined for ADD_TO_TEAM - expect(result.props.user_activity_posts.length).toBe(2); - expect(result.props.user_activity_posts[0].props.addedUserId).toBe('user1'); - expect(result.props.user_activity_posts[1].props.addedUserId).toBe('user2'); + expect((result.props?.user_activity_posts as any).length).toBe(2); + expect((result.props?.user_activity_posts as any)[0].props.addedUserId).toBe('user1'); + expect((result.props?.user_activity_posts as any)[1].props.addedUserId).toBe('user2'); }); it('should combine user activity for REMOVE_FROM_CHANNEL posts correctly', () => { @@ -451,9 +451,9 @@ describe('generateCombinedPost', () => { const result = generateCombinedPost(combinedPostId, systemPosts); // Ensure user activities are combined for REMOVE_FROM_CHANNEL - expect(result.props.user_activity_posts.length).toBe(2); - expect(result.props.user_activity_posts[0].props.removedUserId).toBe('user3'); - expect(result.props.user_activity_posts[1].props.removedUserId).toBe('user4'); + expect((result.props?.user_activity_posts as any).length).toBe(2); + expect((result.props?.user_activity_posts as any)[0].props.removedUserId).toBe('user3'); + expect((result.props?.user_activity_posts as any)[1].props.removedUserId).toBe('user4'); }); it('should handle empty systemPosts gracefully', () => { diff --git a/app/utils/post_list/index.ts b/app/utils/post_list/index.ts index b5f7496659b..76f6e2a212f 100644 --- a/app/utils/post_list/index.ts +++ b/app/utils/post_list/index.ts @@ -6,6 +6,7 @@ import moment from 'moment-timezone'; import {Post} from '@constants'; import {toMilliseconds} from '@utils/datetime'; import {isFromWebhook} from '@utils/post'; +import {ensureString, isArrayOf, isStringArray} from '@utils/types'; import type {PostList, PostWithPrevAndNext} from '@typings/components/post_list'; import type PostModel from '@typings/database/models/servers/post'; @@ -154,9 +155,11 @@ function isJoinLeavePostForUsername(post: PostModel, currentUsername: string): b return false; } - if (post.props.user_activity_posts) { + // We can be more lax with the types here because the recursive function only checks + // whether it is an array, or comparison with strings, so it should be safe enough. + if (Array.isArray(post.props.user_activity_posts)) { for (const childPost of post.props.user_activity_posts as PostModel[]) { - if (isJoinLeavePostForUsername(childPost, currentUsername)) { + if (childPost && isJoinLeavePostForUsername(childPost, currentUsername)) { // If any of the contained posts are for this user, the client will // need to figure out how to render the post return true; @@ -264,8 +267,8 @@ function combineUserActivitySystemPost(systemPosts: PostModel[]) { postType === Post.POST_TYPES.ADD_TO_CHANNEL || postType === Post.POST_TYPES.REMOVE_FROM_CHANNEL ) { - const userId = post.props.addedUserId || post.props.removedUserId; - const username = post.props.addedUsername || post.props.removedUsername; + const userId = ensureString(post.props?.addedUserId) || ensureString(post.props?.removedUserId); + const username = ensureString(post.props?.addedUsername) || ensureString(post.props?.removedUsername); if (combinedPostType) { if (Array.isArray(combinedPostType[post.userId])) { throw new Error('Invalid Post activity data'); @@ -384,3 +387,55 @@ export function shouldFilterJoinLeavePost(post: PostModel, showJoinLeave: boolea // Don't filter out join/leave messages about the current user return !isJoinLeavePostForUsername(post, currentUsername); } + +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; +} + +export 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/app/utils/types.ts b/app/utils/types.ts index 40c8ff17faa..a78a3b8bfc8 100644 --- a/app/utils/types.ts +++ b/app/utils/types.ts @@ -1,6 +1,42 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +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; +} + +export function ensureString(v: unknown): string { + return typeof v === 'string' ? v : ''; +} + +export function ensureNumber(v: unknown): number { + return typeof v === 'number' ? v : 0; +} + export function secureGetFromRecord(v: Record | undefined, key: string) { return typeof v === 'object' && v && Object.prototype.hasOwnProperty.call(v, key) ? v[key] : undefined; } diff --git a/package-lock.json b/package-lock.json index d7a5d4ccd6c..616f26d4736 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@formatjs/intl-numberformat": "8.10.3", "@formatjs/intl-pluralrules": "5.2.14", "@gorhom/bottom-sheet": "4.6.4", - "@mattermost/calls": "github:mattermost/calls-common#07607cf603f1e3f0c86ae248b2332e8da17f9cf8", + "@mattermost/calls": "github:mattermost/calls-common#030ff7c0a37ee9b0ccc5fae0b2ea9c0e1c08473f", "@mattermost/compass-icons": "0.1.45", "@mattermost/hardware-keyboard": "file:./libraries/@mattermost/hardware-keyboard", "@mattermost/keyboard-tracker": "file:./libraries/@mattermost/keyboard-tracker", @@ -5837,11 +5837,11 @@ "node_modules/@mattermost/calls": { "name": "@mattermost/calls-common", "version": "0.27.2", - "resolved": "git+ssh://git@github.com/mattermost/calls-common.git#07607cf603f1e3f0c86ae248b2332e8da17f9cf8", - "integrity": "sha512-aZIYwtgRVj8tdWJUPtAr4TPNa6tNM3lnRkC74CkSsFCpwZ1miwov0B4TLYG4Srj1rTgUqUwG3gQz4o5JHJ2fuQ==", + "resolved": "git+ssh://git@github.com/mattermost/calls-common.git#030ff7c0a37ee9b0ccc5fae0b2ea9c0e1c08473f", + "integrity": "sha512-qtMUUGHrl6VOGgjyQ+Ft4YddWo5RV8MDK8zSaYRU/1MiiY/zqq5kW6nvuzMB2S0xAvPwYlcFEBfmM4QZrReD6w==", "dependencies": { - "@msgpack/msgpack": "^3.0.0-beta2", - "fflate": "^0.8.2" + "@msgpack/msgpack": "3.0.0-beta2", + "fflate": "0.8.2" } }, "node_modules/@mattermost/calls/node_modules/@msgpack/msgpack": { diff --git a/package.json b/package.json index bf9a592feae..4ecea74abe6 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "@formatjs/intl-numberformat": "8.10.3", "@formatjs/intl-pluralrules": "5.2.14", "@gorhom/bottom-sheet": "4.6.4", - "@mattermost/calls": "github:mattermost/calls-common#07607cf603f1e3f0c86ae248b2332e8da17f9cf8", + "@mattermost/calls": "github:mattermost/calls-common#030ff7c0a37ee9b0ccc5fae0b2ea9c0e1c08473f", "@mattermost/compass-icons": "0.1.45", "@mattermost/hardware-keyboard": "file:./libraries/@mattermost/hardware-keyboard", "@mattermost/keyboard-tracker": "file:./libraries/@mattermost/keyboard-tracker", diff --git a/types/api/files.d.ts b/types/api/files.d.ts index 3e5f4d48bc6..0d0affb6fad 100644 --- a/types/api/files.d.ts +++ b/types/api/files.d.ts @@ -22,7 +22,7 @@ type FileInfo = { uri?: string; user_id: string; width: number; - postProps?: Record; + postProps?: Record; }; type FilesState = { diff --git a/types/api/posts.d.ts b/types/api/posts.d.ts index a9b3a3c6031..01d350e0648 100644 --- a/types/api/posts.d.ts +++ b/types/api/posts.d.ts @@ -71,7 +71,7 @@ type Post = { message_source?: string; type: PostType; participants?: null | UserProfile[]|string[]; - props: Record; + props: Record | undefined; hashtags: string; pending_post_id: string; reply_count: number; diff --git a/types/database/models/servers/post.ts b/types/database/models/servers/post.ts index e81b543bd04..e3a9be36a88 100644 --- a/types/database/models/servers/post.ts +++ b/types/database/models/servers/post.ts @@ -71,7 +71,7 @@ declare class PostModel extends Model { userId: string; /** props : Additional attributes for this props */ - props: any; + props: Record | null; /** drafts : Every draft associated with this Post */ drafts: Query; diff --git a/types/screens/gallery.ts b/types/screens/gallery.ts index 44cec0860d9..950586b0590 100644 --- a/types/screens/gallery.ts +++ b/types/screens/gallery.ts @@ -76,7 +76,7 @@ export type GalleryItemType = { authorId?: string; size?: number; postId?: string; - postProps?: Record & {captions?: Caption[]}; + postProps?: Record & {captions?: Caption[]}; }; export type GalleryAction = 'none' | 'downloading' | 'copying' | 'sharing' | 'opening' | 'external';