Add post props validation (#8323)

* Validate props

* Add calls changes and fix attachments

* Address feedback
This commit is contained in:
Daniel Espino García 2024-11-28 15:59:30 +01:00 committed by GitHub
parent f911a29e4c
commit 732b17a75c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 801 additions and 138 deletions

View file

@ -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) {

View file

@ -80,7 +80,7 @@ export async function createPost(serverUrl: string, post: Partial<Post>, 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');

View file

@ -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};

View file

@ -24,7 +24,7 @@ type FilesProps = {
location: string;
isReplyPost: boolean;
postId: string;
postProps: Record<string, any>;
postProps: Record<string, unknown>;
publicLinkEnabled: boolean;
}

View file

@ -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<string, {id?: string; display_name: string; name?: string; team_name: string}>;
export type ChannelMentions = Record<string, {id?: string; display_name: string; name?: string; team_name?: string}>;
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;

View file

@ -12,8 +12,6 @@ import ChannelMention from './channel_mention';
import type {WithDatabaseArgs} from '@typings/database/database';
export type ChannelMentions = Record<string, {id?: string; display_name: string; name?: string; team_name: string}>;
const enhance = withObservables([], ({database}: WithDatabaseArgs) => {
const currentTeamId = observeCurrentTeamId(database);
const channels = currentTeamId.pipe(

View file

@ -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,

View file

@ -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<string>(message.userIds);

View file

@ -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$<Record<string, string>>({});
}
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) => {

View file

@ -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();

View file

@ -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 = () => {

View file

@ -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';

View file

@ -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<AppBinding>(post.props?.app_bindings, isAppBinding) ? validateBindings(post.props.app_bindings) : [];
embeds.forEach((embed, i) => {
content.push(

View file

@ -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<string, string> = {
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 (
<MessageAttachments
attachments={post.props.attachments}
attachments={attachments}
channelId={post.channelId}
layoutWidth={layoutWidth}
location={location}
@ -89,7 +94,7 @@ const Content = ({isReplyPost, layoutWidth, location, post, theme}: ContentProps
}
break;
case contentType.app_bindings:
if (post.props.app_bindings?.length) {
if (nAppBindings) {
return (
<EmbeddedBindings
location={location}

View file

@ -6,7 +6,7 @@ import React, {useCallback, useRef} from 'react';
import {postActionWithCookie} from '@actions/remote/integrations';
import {useServerUrl} from '@context/server';
import {getStatusColors} from '@utils/message_attachment_colors';
import {getStatusColors} from '@utils/message_attachment';
import {preventDoubleTap} from '@utils/tap';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {secureGetFromRecord} from '@utils/types';

View file

@ -5,7 +5,7 @@ import React from 'react';
import {View} from 'react-native';
import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
import {getStatusColors} from '@utils/message_attachment_colors';
import {getStatusColors} from '@utils/message_attachment';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {secureGetFromRecord} from '@utils/types';
import {isValidUrl} from '@utils/url';

View file

@ -96,8 +96,11 @@ const Body = ({
let body;
let message;
const nBindings = Array.isArray(post.props?.app_bindings) ? post.props?.app_bindings.length : 0;
const nAttachments = Array.isArray(post.props?.attachments) ? post.props?.attachments.length : 0;
const isReplyPost = Boolean(post.rootId && (!isEphemeral || !hasBeenDeleted) && location !== THREAD);
const hasContent = Boolean((post.metadata?.embeds?.length || (appsEnabled && post.props?.app_bindings?.length)) || post.props?.attachments?.length);
const hasContent = Boolean((post.metadata?.embeds?.length || (appsEnabled && nBindings)) || nAttachments);
const replyBarStyle = useCallback((): StyleProp<ViewStyle>|undefined => {
if (!isReplyPost || (isCRTEnabled && location === Screens.PERMALINK)) {

View file

@ -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 (
<>
<Animated.View style={animatedStyle}>
@ -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 !== '')}
/>
</View>
</ScrollView>

View file

@ -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!}
/>

View file

@ -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.',

View file

@ -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<string>, posts: Post[], excludeUsername?: string) => {
const usernamesToLoad = new Set<string>();
@ -36,8 +37,9 @@ export const getNeededAtMentionedUsernames = (usernames: Set<string>, 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);
}

View file

@ -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,

View file

@ -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);
});
});

View file

@ -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<string, any> & { captions?: Caption[] }): boolean => {
return !(!postProps || !postProps.captions?.[0]);
export const hasCaptions = (postProps?: Record<string, unknown>): boolean => {
return Boolean(isArrayOf<Caption>(postProps?.captions, isCaption) && postProps.captions[0]);
};
export const getTranscriptionUri = (serverUrl: string, postProps?: Record<string, any> & { captions?: Caption[] }): {
export const getTranscriptionUri = (serverUrl: string, postProps?: Record<string, unknown>): {
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<Caption>(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<string
};
};
function isValidObject(v: any) {
return typeof v === 'object' && !Array.isArray(v) && v !== null;
}
export function getCallPropsFromPost(post: PostModel | Post): CallPostProps {
return {
title: typeof post.props?.title === 'string' ? post.props.title : '',
start_at: typeof post.props?.start_at === 'number' ? post.props.start_at : 0,
end_at: typeof post.props?.end_at === 'number' ? post.props.end_at : 0,
recordings: isValidObject(post.props?.recordings) ? post.props.recordings : {},
transcriptions: isValidObject(post.props?.transcriptions) ? post.props.transcriptions : {},
participants: Array.isArray(post.props?.participants) ? post.props.participants : [],
title: ensureString(post.props?.title),
start_at: ensureNumber(post.props?.start_at),
end_at: ensureNumber(post.props?.end_at),
recordings: isRecordOf<CallJobMetadata>(post.props?.recordings, isCallJobMetadata) ? post.props.recordings : {},
transcriptions: isRecordOf<CallJobMetadata>(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 : [],
};
}

View file

@ -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);
}

View file

@ -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<any>
};
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;
}

View file

@ -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<string, any>, lastPictureUpdate = 0): GalleryItemType => {
export const fileToGalleryItem = (file: FileInfo, authorId?: string, postProps?: Record<string, unknown>, lastPictureUpdate = 0): GalleryItemType => {
let type: GalleryItemType['type'] = 'file';
if (isVideo(file)) {
type = 'video';

View file

@ -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<string> {
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;
}

View file

@ -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;

View file

@ -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<string> {
return {
good: '#00c100',
warning: '#dede01',
danger: theme.errorTextColor,
default: theme.centerChannelColor,
primary: theme.buttonBg,
success: theme.onlineIndicator,
};
}

View file

@ -85,7 +85,7 @@ describe('post utils', () => {
const post = {
props: {
from_webhook: 'true',
},
} as Record<string, unknown>,
} as PostModel;
const result = isFromWebhook(post);
@ -96,7 +96,7 @@ describe('post utils', () => {
const post = {
props: {
from_webhook: 'false',
},
} as Record<string, unknown>,
} as PostModel;
const result = isFromWebhook(post);
@ -149,7 +149,7 @@ describe('post utils', () => {
const post = {
props: {
failed: true,
},
} as Record<string, unknown>,
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<string, unknown>,
} as PostModel;
const result = postUserDisplayName(post, undefined, undefined, true);
@ -482,7 +482,7 @@ describe('post utils', () => {
const post = {
props: {
from_webhook: 'false',
},
} as Record<string, unknown>,
} as PostModel;
const author = {
username: 'user1',
@ -497,7 +497,7 @@ describe('post utils', () => {
const post = {
props: {
from_webhook: 'false',
},
} as Record<string, unknown>,
} as PostModel;
const author = {
username: 'user1',

View file

@ -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);

View file

@ -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', () => {

View file

@ -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;
}

View file

@ -1,6 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export function isArrayOf<T>(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<T>(v: unknown, check: (e: unknown) => boolean): v is Record<string, T> {
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<T>(v: Record<string, T> | undefined, key: string) {
return typeof v === 'object' && v && Object.prototype.hasOwnProperty.call(v, key) ? v[key] : undefined;
}

10
package-lock.json generated
View file

@ -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": {

View file

@ -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",

View file

@ -22,7 +22,7 @@ type FileInfo = {
uri?: string;
user_id: string;
width: number;
postProps?: Record<string, any>;
postProps?: Record<string, unknown>;
};
type FilesState = {

View file

@ -71,7 +71,7 @@ type Post = {
message_source?: string;
type: PostType;
participants?: null | UserProfile[]|string[];
props: Record<string, any>;
props: Record<string, unknown> | undefined;
hashtags: string;
pending_post_id: string;
reply_count: number;

View file

@ -71,7 +71,7 @@ declare class PostModel extends Model {
userId: string;
/** props : Additional attributes for this props */
props: any;
props: Record<string, unknown> | null;
/** drafts : Every draft associated with this Post */
drafts: Query<DraftModel>;

View file

@ -76,7 +76,7 @@ export type GalleryItemType = {
authorId?: string;
size?: number;
postId?: string;
postProps?: Record<string, any> & {captions?: Caption[]};
postProps?: Record<string, unknown> & {captions?: Caption[]};
};
export type GalleryAction = 'none' | 'downloading' | 'copying' | 'sharing' | 'opening' | 'external';