Add post props validation (#29017)
* Add post props validation * Fix tests and revert deletion * Fix i18n * fix tests * Add some tests * Fix tests * Address feedback * Fix lint * Fix message attachments
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
01b8359347
Коммит
2e13cbb84d
@@ -3,7 +3,7 @@
|
||||
|
||||
import type {Channel, ChannelMembership} from '@mattermost/types/channels';
|
||||
import type {ServerError} from '@mattermost/types/errors';
|
||||
import type {MessageAttachment} from '@mattermost/types/message_attachments';
|
||||
import {isMessageAttachmentArray} from '@mattermost/types/message_attachments';
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
@@ -19,7 +19,7 @@ import {getAllUserMentionKeys} from 'mattermost-redux/selectors/entities/search'
|
||||
import {getCurrentUserId, getCurrentUser, getStatusForUserId, getUser} from 'mattermost-redux/selectors/entities/users';
|
||||
import type {ActionFuncAsync} from 'mattermost-redux/types/actions';
|
||||
import {isChannelMuted} from 'mattermost-redux/utils/channel_utils';
|
||||
import {isSystemMessage, isUserAddedInChannel} from 'mattermost-redux/utils/post_utils';
|
||||
import {ensureString, isSystemMessage, isUserAddedInChannel} from 'mattermost-redux/utils/post_utils';
|
||||
import {displayUsername} from 'mattermost-redux/utils/user_utils';
|
||||
|
||||
import {getChannelURL, getPermalinkURL} from 'selectors/urls';
|
||||
@@ -197,12 +197,13 @@ const getNotificationTitle = (channel: Pick<Channel, 'type' | 'display_name'>, m
|
||||
return title;
|
||||
};
|
||||
|
||||
const getNotificationUsername = (state: GlobalState, post: Post, msgProps: NewPostMessageProps) => {
|
||||
const getNotificationUsername = (state: GlobalState, post: Post, msgProps: NewPostMessageProps): string => {
|
||||
const config = getConfig(state);
|
||||
const userFromPost = getUser(state, post.user_id);
|
||||
|
||||
if (post.props.override_username && config.EnablePostUsernameOverride === 'true') {
|
||||
return post.props.override_username;
|
||||
const overrideUsername = ensureString(post.props.override_username);
|
||||
if (overrideUsername && config.EnablePostUsernameOverride === 'true') {
|
||||
return overrideUsername;
|
||||
}
|
||||
if (userFromPost) {
|
||||
return displayUsername(userFromPost, getTeammateNameDisplaySetting(state), false);
|
||||
@@ -219,15 +220,15 @@ const getNotificationBody = (state: GlobalState, post: Post, msgProps: NewPostMe
|
||||
let notifyText = post.message;
|
||||
|
||||
const msgPropsPost: Post = JSON.parse(msgProps.post);
|
||||
const attachments: MessageAttachment[] = msgPropsPost && msgPropsPost.props && msgPropsPost.props.attachments ? msgPropsPost.props.attachments : [];
|
||||
const attachments = isMessageAttachmentArray(msgPropsPost?.props?.attachments) ? msgPropsPost.props.attachments : [];
|
||||
let image = false;
|
||||
attachments.forEach((attachment) => {
|
||||
if (notifyText.length === 0) {
|
||||
notifyText = attachment.fallback ||
|
||||
attachment.pretext ||
|
||||
attachment.text;
|
||||
attachment.text || '';
|
||||
}
|
||||
image = image || (attachment.image_url.length > 0);
|
||||
image = Boolean(image || (attachment.image_url?.length));
|
||||
});
|
||||
|
||||
const strippedMarkdownNotifyText = stripMarkdown(notifyText);
|
||||
@@ -316,9 +317,9 @@ function shouldSkipNotification(
|
||||
|
||||
// We do this on a try catch block to avoid errors from malformed props
|
||||
try {
|
||||
if (post.props && post.props.attachments) {
|
||||
if (isMessageAttachmentArray(post.props.attachments)) {
|
||||
const attachments = post.props.attachments;
|
||||
function appendText(toAppend: string) {
|
||||
function appendText(toAppend?: string) {
|
||||
if (toAppend) {
|
||||
text += `\n${toAppend}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {DeepPartial} from '@mattermost/types/utilities';
|
||||
|
||||
import {isCustomPostProps, type CustomPostProps} from '.';
|
||||
|
||||
describe('isCustomPostProps', () => {
|
||||
it('no content', () => {
|
||||
const props: CustomPostProps = {
|
||||
requested_plugins_by_plugin_ids: {},
|
||||
requested_plugins_by_user_ids: {},
|
||||
};
|
||||
expect(isCustomPostProps(props)).toBe(true);
|
||||
});
|
||||
|
||||
it('content but no elements', () => {
|
||||
const props: CustomPostProps = {
|
||||
requested_plugins_by_plugin_ids: {'some id': []},
|
||||
requested_plugins_by_user_ids: {'some id': []},
|
||||
};
|
||||
expect(isCustomPostProps(props)).toBe(true);
|
||||
});
|
||||
|
||||
it('content with elements', () => {
|
||||
const props: CustomPostProps = {
|
||||
requested_plugins_by_plugin_ids: {'some id': [{
|
||||
user_id: '123',
|
||||
}]},
|
||||
requested_plugins_by_user_ids: {'some id': [{
|
||||
user_id: '123',
|
||||
}]},
|
||||
};
|
||||
expect(isCustomPostProps(props)).toBe(true);
|
||||
});
|
||||
|
||||
it('all values are required', () => {
|
||||
const baseProp: CustomPostProps = {
|
||||
requested_plugins_by_plugin_ids: {},
|
||||
requested_plugins_by_user_ids: {},
|
||||
};
|
||||
|
||||
expect(isCustomPostProps(baseProp)).toBe(true);
|
||||
|
||||
for (const key of Object.keys(baseProp)) {
|
||||
const wrongProp: Partial<CustomPostProps> = {...baseProp};
|
||||
delete wrongProp[key as keyof CustomPostProps];
|
||||
expect(isCustomPostProps(wrongProp)).toBe(false);
|
||||
}
|
||||
|
||||
const wrongProp: DeepPartial<CustomPostProps> = {
|
||||
requested_plugins_by_plugin_ids: {'some id': [{}]},
|
||||
requested_plugins_by_user_ids: {'some id': []},
|
||||
};
|
||||
expect(isCustomPostProps(wrongProp)).toBe(false);
|
||||
});
|
||||
|
||||
it('common false cases', () => {
|
||||
expect(isCustomPostProps('')).toBe(false);
|
||||
expect(isCustomPostProps(undefined)).toBe(false);
|
||||
expect(isCustomPostProps(true)).toBe(false);
|
||||
expect(isCustomPostProps(1)).toBe(false);
|
||||
expect(isCustomPostProps([])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,14 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import uniqWith from 'lodash/uniqWith';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import React, {useEffect, useMemo} from 'react';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
import {useSelector, useDispatch} from 'react-redux';
|
||||
import {Link} from 'react-router-dom';
|
||||
|
||||
import type {MarketplacePlugin} from '@mattermost/types/marketplace';
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
import {isArrayOf, isRecordOf} from '@mattermost/types/utilities';
|
||||
|
||||
import {getMissingProfilesByIds} from 'mattermost-redux/actions/users';
|
||||
import {getUsers} from 'mattermost-redux/selectors/entities/users';
|
||||
@@ -24,23 +25,52 @@ import {ModalIdentifiers} from 'utils/constants';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
// We only define the props used in this component for
|
||||
// clarity. If more props are needed in the future,
|
||||
// feel free to add them.
|
||||
type PluginRequest = {
|
||||
user_id: string;
|
||||
required_feature: string;
|
||||
required_plan: string;
|
||||
create_at: string;
|
||||
sent_at: string;
|
||||
plugin_name: string;
|
||||
plugin_id: string;
|
||||
}
|
||||
|
||||
function isPluginRequest(v: unknown): v is PluginRequest {
|
||||
if (typeof v !== 'object' || v === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const request = v as PluginRequest;
|
||||
|
||||
if (typeof request.user_id !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
type RequestedPlugins = Record<string, PluginRequest[]>
|
||||
|
||||
type CustomPostProps = {
|
||||
export type CustomPostProps = {
|
||||
requested_plugins_by_plugin_ids: RequestedPlugins;
|
||||
requested_plugins_by_user_ids: RequestedPlugins;
|
||||
}
|
||||
|
||||
export function isCustomPostProps(v: unknown): v is CustomPostProps {
|
||||
if (typeof v !== 'object' || !v) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const props = v as CustomPostProps;
|
||||
|
||||
if (!isRecordOf(props.requested_plugins_by_plugin_ids, (e) => isArrayOf(e, isPluginRequest))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isRecordOf(props.requested_plugins_by_user_ids, (e) => isArrayOf(e, isPluginRequest))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const usersListStyle = {
|
||||
margin: '20px 0',
|
||||
};
|
||||
@@ -56,7 +86,7 @@ const InstallLink = (props: {pluginId: string; pluginName: string}) => {
|
||||
>
|
||||
<FormattedMessage
|
||||
id='marketplace_modal.list.install.plugin'
|
||||
defaultMessage={`Install ${props.pluginName}`}
|
||||
defaultMessage={'Install {plugin}'}
|
||||
values={{
|
||||
plugin: props.pluginName,
|
||||
}}
|
||||
@@ -82,7 +112,7 @@ const ConfigureLink = (props: {pluginId: string; pluginName: string}) => {
|
||||
>
|
||||
<FormattedMessage
|
||||
id='marketplace_modal.list.configure.plugin'
|
||||
defaultMessage={`Configure ${props.pluginName}`}
|
||||
defaultMessage={'Configure {plugin}'}
|
||||
values={{
|
||||
plugin: props.pluginName,
|
||||
}}
|
||||
@@ -128,14 +158,19 @@ export default function OpenPluginInstallPost(props: {post: Post}) {
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const {formatMessage, formatList} = useIntl();
|
||||
const [pluginsByPluginIds, setPluginsByPluginIds] = useState<RequestedPlugins>({});
|
||||
|
||||
const postProps = props.post.props as CustomPostProps;
|
||||
const postProps = isCustomPostProps(props.post.props) ? props.post.props : undefined;
|
||||
const requestedPluginsByPluginIds = postProps?.requested_plugins_by_plugin_ids;
|
||||
const requestedPluginsByUserIds = postProps?.requested_plugins_by_user_ids;
|
||||
|
||||
const userProfiles = useSelector(getUsers);
|
||||
const marketplacePlugins: MarketplacePlugin[] = useSelector(getPlugins);
|
||||
const marketplacePluginsNamesById = useMemo(() => {
|
||||
return marketplacePlugins.reduce<Record<string, string>>((acc, v) => {
|
||||
acc[v.manifest.id] = v.manifest.name;
|
||||
return acc;
|
||||
}, {});
|
||||
}, [marketplacePlugins]);
|
||||
|
||||
const getUserIdsForUsersThatRequestedFeature = (requests: PluginRequest[]): string[] => requests.map((request: PluginRequest) => request.user_id);
|
||||
|
||||
@@ -143,32 +178,16 @@ export default function OpenPluginInstallPost(props: {post: Post}) {
|
||||
if (!marketplacePlugins.length) {
|
||||
dispatch(fetchListing());
|
||||
}
|
||||
}, [dispatch, fetchListing, marketplacePlugins.length]);
|
||||
}, [dispatch, marketplacePlugins.length]);
|
||||
|
||||
useEffect(() => {
|
||||
// process the plugins once the marketplace plugins are fetched and the plugins are available from the props
|
||||
if (requestedPluginsByPluginIds && marketplacePlugins.length && !Object.keys(pluginsByPluginIds).length) {
|
||||
const plugins = {} as RequestedPlugins;
|
||||
const mPlugins = marketplacePlugins.reduce((acc, mPlugin) => {
|
||||
return {
|
||||
...acc,
|
||||
[mPlugin.manifest.id as keyof string]: mPlugin,
|
||||
};
|
||||
}, {}) as {[ key: string]: MarketplacePlugin};
|
||||
|
||||
if (requestedPluginsByPluginIds && marketplacePlugins.length) {
|
||||
for (const pluginId of Object.keys(requestedPluginsByPluginIds)) {
|
||||
plugins[pluginId] = requestedPluginsByPluginIds[pluginId].map((currPlugin: PluginRequest) => {
|
||||
return {
|
||||
...currPlugin,
|
||||
plugin_name: mPlugins[pluginId].manifest.name || pluginId,
|
||||
plugin_id: pluginId,
|
||||
};
|
||||
});
|
||||
dispatch(getMissingProfilesByIds(getUserIdsForUsersThatRequestedFeature(requestedPluginsByPluginIds[pluginId])));
|
||||
}
|
||||
setPluginsByPluginIds(plugins);
|
||||
}
|
||||
}, [dispatch, marketplacePlugins, requestedPluginsByPluginIds, pluginsByPluginIds]);
|
||||
}, [dispatch, marketplacePlugins, requestedPluginsByPluginIds]);
|
||||
|
||||
const createUsernameMessage = (requests: PluginRequest[]) => {
|
||||
if (requests.length >= 5) {
|
||||
@@ -209,13 +228,13 @@ export default function OpenPluginInstallPost(props: {post: Post}) {
|
||||
atPlanMentions: true,
|
||||
markdown: false,
|
||||
};
|
||||
const pluginIds = Object.keys(pluginsByPluginIds);
|
||||
if (pluginIds.length && requestedPluginsByUserIds) {
|
||||
const pluginIds = Object.keys(requestedPluginsByPluginIds || {});
|
||||
if (pluginIds.length && requestedPluginsByUserIds && requestedPluginsByPluginIds) {
|
||||
let post;
|
||||
const messageBuilder: string[] = [];
|
||||
const userIds = Object.keys(requestedPluginsByUserIds);
|
||||
if (userIds.length === 1 && pluginIds.length === 1) {
|
||||
const pluginName = pluginsByPluginIds[pluginIds[0]][0].plugin_name;
|
||||
const pluginName = marketplacePluginsNamesById[pluginIds[0]];
|
||||
messageBuilder.push('@' + userProfiles[userIds[0]]?.username);
|
||||
messageBuilder.push(' ' + formatMessage({id: 'postypes.custom_open_plugin_install_post_rendered.plugin_request', defaultMessage: 'requested installing the {pluginRequests} app.'}, {pluginRequests: pluginName}));
|
||||
|
||||
@@ -258,19 +277,18 @@ export default function OpenPluginInstallPost(props: {post: Post}) {
|
||||
customMessageBody.push(post);
|
||||
} else {
|
||||
messageBuilder.push(formatMessage({id: 'postypes.custom_open_plugin_install_post_rendered.app_installation_request_text', defaultMessage: 'You’ve received the following app installation requests:'}));
|
||||
const pluginIds = Object.keys(pluginsByPluginIds);
|
||||
|
||||
post = (
|
||||
<ul
|
||||
style={usersListStyle}
|
||||
key={pluginIds.join('')}
|
||||
>
|
||||
{pluginIds.map((pluginId) => {
|
||||
const plugins = pluginsByPluginIds[pluginId];
|
||||
const plugins = requestedPluginsByPluginIds[pluginId];
|
||||
const pluginName = marketplacePluginsNamesById[pluginId];
|
||||
const uniqueUserRequestsForPlugins = uniqWith(plugins, (one, two) => one.user_id === two.user_id);
|
||||
const installRequests = [];
|
||||
installRequests.push(createUsernameMessage(uniqueUserRequestsForPlugins));
|
||||
installRequests.push(' ' + formatMessage({id: 'postypes.custom_open_plugin_install_post_rendered.plugin_request', defaultMessage: 'requested installing the {pluginRequests} app.'}, {pluginRequests: uniqueUserRequestsForPlugins[0].plugin_name}));
|
||||
installRequests.push(' ' + formatMessage({id: 'postypes.custom_open_plugin_install_post_rendered.plugin_request', defaultMessage: 'requested installing the {pluginRequests} app.'}, {pluginRequests: pluginName}));
|
||||
|
||||
return (
|
||||
<li key={pluginId}>
|
||||
@@ -283,7 +301,7 @@ export default function OpenPluginInstallPost(props: {post: Post}) {
|
||||
{' '}
|
||||
<InstallAndConfigureLink
|
||||
pluginId={pluginId}
|
||||
pluginName={uniqueUserRequestsForPlugins[0].plugin_name}
|
||||
pluginName={pluginName}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,8 @@ import {FormattedMessage, useIntl} from 'react-intl';
|
||||
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
|
||||
import {ensureString} from 'mattermost-redux/utils/post_utils';
|
||||
|
||||
import PostHeaderCustomStatus from 'components/post_view/post_header_custom_status/post_header_custom_status';
|
||||
import UserProfile from 'components/user_profile';
|
||||
import BotTag from 'components/widgets/tag/bot_tag';
|
||||
@@ -75,7 +77,10 @@ const PostUserProfile = (props: Props): JSX.Element | null => {
|
||||
);
|
||||
|
||||
if (isFromWebhook(post)) {
|
||||
const overwriteName = post.props.override_username && enablePostUsernameOverride ? post.props.override_username : undefined;
|
||||
const propOverrideName = ensureString(post.props.override_username);
|
||||
const overwriteName = propOverrideName && enablePostUsernameOverride ? propOverrideName : undefined;
|
||||
const propOverrideIcon = ensureString(post.props.override_icon_url);
|
||||
const overwriteIcon = propOverrideIcon || undefined;
|
||||
userProfile = (
|
||||
<UserProfile
|
||||
userId={post.user_id}
|
||||
@@ -83,7 +88,7 @@ const PostUserProfile = (props: Props): JSX.Element | null => {
|
||||
hideStatus={true}
|
||||
overwriteName={overwriteName}
|
||||
colorize={colorize}
|
||||
overwriteIcon={post.props.override_icon_url || undefined}
|
||||
overwriteIcon={overwriteIcon}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -216,6 +216,7 @@ exports[`components/post_edit_history/edited_post_item should match snapshot whe
|
||||
>
|
||||
<Connect(UserProfile)
|
||||
disablePopover={true}
|
||||
overwriteName=""
|
||||
userId="user_id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {CheckIcon} from '@mattermost/compass-icons/components';
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
|
||||
import type {Theme} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {ensureString} from 'mattermost-redux/utils/post_utils';
|
||||
|
||||
import CompassThemeProvider from 'components/compass_theme_provider/compass_theme_provider';
|
||||
import InfoToast from 'components/info_toast/info_toast';
|
||||
@@ -131,7 +132,7 @@ const EditedPostItem = ({post, isCurrent = false, postCurrentVersion, theme, act
|
||||
|
||||
const profileSrc = imageURLForUser(post.user_id);
|
||||
|
||||
const overwriteName = post.props ? post.props.override_username : '';
|
||||
const overwriteName = ensureString(post.props?.override_username);
|
||||
const postHeader = (
|
||||
<div className='edit-post-history__header'>
|
||||
<span className='profile-icon'>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {Posts} from 'mattermost-redux/constants';
|
||||
|
||||
import Markdown from 'components/markdown';
|
||||
|
||||
import type {TextFormattingOptions} from 'utils/text_formatting';
|
||||
import {isChannelNamesMap, type TextFormattingOptions} from 'utils/text_formatting';
|
||||
|
||||
import {renderReminderSystemBotMessage, renderSystemMessage, renderWranglerSystemMessage} from './system_message_helpers';
|
||||
|
||||
@@ -92,7 +92,7 @@ export default class PostMarkdown extends React.PureComponent<Props> {
|
||||
|
||||
// Proxy images if we have an image proxy and the server hasn't already rewritten the this.props.post's image URLs.
|
||||
const proxyImages = !this.props.post || !this.props.post.message_source || this.props.post.message === this.props.post.message_source;
|
||||
const channelNamesMap = this.props.post && this.props.post.props && this.props.post.props.channel_mentions;
|
||||
const channelNamesMap = isChannelNamesMap(this.props.post?.props?.channel_mentions) ? this.props.post?.props?.channel_mentions : undefined;
|
||||
|
||||
this.props.pluginHooks?.forEach((o) => {
|
||||
if (o && o.hook && this.props.post) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {AddMemberProps} from './system_message_helpers';
|
||||
import {isAddMemberProps} from './system_message_helpers';
|
||||
|
||||
describe('isAddMemberProps', () => {
|
||||
it('with empty lists', () => {
|
||||
const prop: AddMemberProps = {
|
||||
post_id: '',
|
||||
not_in_channel_user_ids: [],
|
||||
not_in_channel_usernames: [],
|
||||
not_in_groups_usernames: [],
|
||||
};
|
||||
|
||||
expect(isAddMemberProps(prop)).toBe(true);
|
||||
});
|
||||
|
||||
it('with values in lists', () => {
|
||||
const prop: AddMemberProps = {
|
||||
post_id: '',
|
||||
not_in_channel_user_ids: ['hello', 'world'],
|
||||
not_in_channel_usernames: ['hello', 'world'],
|
||||
not_in_groups_usernames: ['hello', 'world'],
|
||||
};
|
||||
|
||||
expect(isAddMemberProps(prop)).toBe(true);
|
||||
});
|
||||
|
||||
it('all values are required', () => {
|
||||
const baseProp: AddMemberProps = {
|
||||
post_id: '',
|
||||
not_in_channel_user_ids: [],
|
||||
not_in_channel_usernames: [],
|
||||
not_in_groups_usernames: [],
|
||||
};
|
||||
|
||||
expect(isAddMemberProps(baseProp)).toBe(true);
|
||||
|
||||
for (const key of Object.keys(baseProp)) {
|
||||
const wrongProp: Partial<AddMemberProps> = {...baseProp};
|
||||
delete wrongProp[key as keyof AddMemberProps];
|
||||
expect(isAddMemberProps(wrongProp)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('common false cases', () => {
|
||||
expect(isAddMemberProps('')).toBe(false);
|
||||
expect(isAddMemberProps(undefined)).toBe(false);
|
||||
expect(isAddMemberProps(true)).toBe(false);
|
||||
expect(isAddMemberProps(1)).toBe(false);
|
||||
expect(isAddMemberProps([])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -8,20 +8,23 @@ import {FormattedDate, FormattedMessage, FormattedTime, defineMessages} from 're
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
import type {Team} from '@mattermost/types/teams';
|
||||
import {isStringArray} from '@mattermost/types/utilities';
|
||||
|
||||
import {General, Posts} from 'mattermost-redux/constants';
|
||||
import {isPostEphemeral} from 'mattermost-redux/utils/post_utils';
|
||||
import {isUserActivityProp} from 'mattermost-redux/utils/post_list';
|
||||
import {ensureNumber, ensureString, isPostEphemeral} from 'mattermost-redux/utils/post_utils';
|
||||
|
||||
import Markdown from 'components/markdown';
|
||||
import CombinedSystemMessage from 'components/post_view/combined_system_message';
|
||||
import GMConversionMessage from 'components/post_view/gm_conversion_message/gm_conversion_message';
|
||||
import PostAddChannelMember from 'components/post_view/post_add_channel_member';
|
||||
|
||||
import type {TextFormattingOptions} from 'utils/text_formatting';
|
||||
import {isChannelNamesMap, type TextFormattingOptions} from 'utils/text_formatting';
|
||||
import {getSiteURL} from 'utils/url';
|
||||
|
||||
export function renderUsername(value: string): ReactNode {
|
||||
const username = (value[0] === '@') ? value : `@${value}`;
|
||||
export function renderUsername(value: unknown): ReactNode {
|
||||
const verifiedValue = ensureString(value);
|
||||
const username = (verifiedValue[0] === '@') ? verifiedValue : `@${verifiedValue}`;
|
||||
|
||||
const options = {
|
||||
markdown: false,
|
||||
@@ -30,10 +33,11 @@ export function renderUsername(value: string): ReactNode {
|
||||
return renderFormattedText(username, options);
|
||||
}
|
||||
|
||||
function renderFormattedText(value: string, options?: Partial<TextFormattingOptions>, post?: Post): ReactNode {
|
||||
function renderFormattedText(value: unknown, options?: Partial<TextFormattingOptions>, post?: Post): ReactNode {
|
||||
const verifiedValue = ensureString(value);
|
||||
return (
|
||||
<Markdown
|
||||
message={value}
|
||||
message={verifiedValue}
|
||||
options={options}
|
||||
postId={post && post.id}
|
||||
postType={post && post.type}
|
||||
@@ -189,7 +193,7 @@ function renderHeaderChangeMessage(post: Post): ReactNode {
|
||||
}
|
||||
|
||||
const headerOptions = {
|
||||
channelNamesMap: post.props && post.props.channel_mentions,
|
||||
channelNamesMap: isChannelNamesMap(post.props?.channel_mentions) ? post.props.channel_mentions : undefined,
|
||||
mentionHighlight: true,
|
||||
};
|
||||
|
||||
@@ -388,12 +392,43 @@ const systemMessageRenderers = {
|
||||
[Posts.POST_TYPES.ME]: renderMeMessage,
|
||||
};
|
||||
|
||||
export type AddMemberProps = {
|
||||
post_id: string;
|
||||
not_in_channel_user_ids: string[];
|
||||
not_in_groups_usernames: string[];
|
||||
not_in_channel_usernames: string[];
|
||||
}
|
||||
|
||||
export function isAddMemberProps(v: unknown): v is AddMemberProps {
|
||||
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;
|
||||
}
|
||||
|
||||
export function renderSystemMessage(post: Post, currentTeamName: string, channel: Channel, hideGuestTags: boolean, isUserCanManageMembers?: boolean, isMilitaryTime?: boolean, timezone?: string): ReactNode {
|
||||
const isEphemeral = isPostEphemeral(post);
|
||||
if (isEphemeral && post.props?.type === Posts.POST_TYPES.REMINDER) {
|
||||
return renderReminderACKMessage(post, currentTeamName, Boolean(isMilitaryTime), timezone);
|
||||
}
|
||||
if (post.props && post.props.add_channel_member) {
|
||||
if (isAddMemberProps(post.props?.add_channel_member)) {
|
||||
if (channel && (channel.type === General.PRIVATE_CHANNEL || channel.type === General.OPEN_CHANNEL) &&
|
||||
isUserCanManageMembers &&
|
||||
isEphemeral
|
||||
@@ -416,7 +451,7 @@ export function renderSystemMessage(post: Post, currentTeamName: string, channel
|
||||
return renderGuestJoinChannelMessage(post, hideGuestTags);
|
||||
} else if (post.type === Posts.POST_TYPES.ADD_GUEST_TO_CHANNEL) {
|
||||
return renderAddGuestToChannelMessage(post, hideGuestTags);
|
||||
} else if (post.type === Posts.POST_TYPES.COMBINED_USER_ACTIVITY) {
|
||||
} else if (post.type === Posts.POST_TYPES.COMBINED_USER_ACTIVITY && isUserActivityProp(post.props.user_activity)) {
|
||||
const {allUserIds, allUsernames, messageData} = post.props.user_activity;
|
||||
|
||||
return (
|
||||
@@ -443,7 +478,8 @@ function renderReminderACKMessage(post: Post, currentTeamName: string, isMilitar
|
||||
const teamUrl = `${getSiteURL()}/${post.props.team_name || currentTeamName}`;
|
||||
const link = `${teamUrl}/pl/${post.props.post_id}`;
|
||||
const permaLink = renderFormattedText(`[${link}](${link})`);
|
||||
const localTime = new Date(post.props.target_time * 1000);
|
||||
const targetTime = ensureNumber(post.props.target_time);
|
||||
const localTime = new Date(targetTime * 1000);
|
||||
|
||||
const reminderTime = (
|
||||
<FormattedTime
|
||||
@@ -515,13 +551,15 @@ defineMessages({
|
||||
});
|
||||
|
||||
export function renderWranglerSystemMessage(post: Post): ReactNode {
|
||||
let values = {} as any;
|
||||
const id = post.props.TranslationID;
|
||||
if (post.props && post.props.MovedThreadPermalink) {
|
||||
let values: React.ComponentProps<typeof FormattedMessage>['values'] = {};
|
||||
const id = ensureString(post.props?.TranslationID);
|
||||
const movedThreadPermalink = ensureString(post.props?.MovedThreadPermalink);
|
||||
if (movedThreadPermalink) {
|
||||
values = {
|
||||
link: post.props.MovedThreadPermalink,
|
||||
link: movedThreadPermalink,
|
||||
};
|
||||
if (post.props.NumMessages > 1) {
|
||||
const numMessages = ensureNumber(post.props.NumMessages);
|
||||
if (numMessages > 1) {
|
||||
values.number = post.props.NumMessages;
|
||||
}
|
||||
}
|
||||
@@ -533,4 +571,3 @@ export function renderWranglerSystemMessage(post: Post): ReactNode {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {Client4} from 'mattermost-redux/client';
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||
import {get} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {getUser, getStatusForUserId} from 'mattermost-redux/selectors/entities/users';
|
||||
import {ensureString} from 'mattermost-redux/utils/post_utils';
|
||||
|
||||
import {Preferences} from 'utils/constants';
|
||||
|
||||
@@ -26,7 +27,7 @@ function mapStateToProps(state: GlobalState, ownProps: Props) {
|
||||
const user = getUser(state, ownProps.userId);
|
||||
const enablePostIconOverride = config.EnablePostIconOverride === 'true';
|
||||
const availabilityStatusOnPosts = get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.AVAILABILITY_STATUS_ON_POSTS, Preferences.AVAILABILITY_STATUS_ON_POSTS_DEFAULT);
|
||||
const overrideIconUrl = enablePostIconOverride && ownProps.post && ownProps.post.props && ownProps.post.props.override_icon_url;
|
||||
const overrideIconUrl = enablePostIconOverride && ensureString(ownProps.post?.props?.override_icon_url);
|
||||
let overwriteIcon;
|
||||
if (overrideIconUrl) {
|
||||
overwriteIcon = Client4.getAbsoluteUrl(overrideIconUrl);
|
||||
|
||||
@@ -6,6 +6,8 @@ import React from 'react';
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import {ensureString} from 'mattermost-redux/utils/post_utils';
|
||||
|
||||
import ProfilePicture from 'components/profile_picture';
|
||||
import MattermostLogo from 'components/widgets/icons/mattermost_logo';
|
||||
|
||||
@@ -53,12 +55,8 @@ export default class PostProfilePicture extends React.PureComponent<Props> {
|
||||
getPostIconURL = (defaultURL: string, fromAutoResponder: boolean, fromWebhook: boolean): string => {
|
||||
const {enablePostIconOverride, hasImageProxy, post} = this.props;
|
||||
const postProps = post.props;
|
||||
let postIconOverrideURL = '';
|
||||
let useUserIcon = '';
|
||||
if (postProps) {
|
||||
postIconOverrideURL = postProps.override_icon_url;
|
||||
useUserIcon = postProps.use_user_icon;
|
||||
}
|
||||
const postIconOverrideURL = ensureString(postProps?.override_icon_url);
|
||||
const useUserIcon = ensureString(postProps?.use_user_icon);
|
||||
|
||||
if (this.props.compactDisplay) {
|
||||
return '';
|
||||
@@ -95,9 +93,9 @@ export default class PostProfilePicture extends React.PureComponent<Props> {
|
||||
const profileSrc = this.getProfilePictureURL();
|
||||
const src = this.getPostIconURL(profileSrc, fromAutoResponder, fromWebhook);
|
||||
|
||||
const overrideIconEmoji = post.props ? post.props.override_icon_emoji : '';
|
||||
const overwriteName = post.props ? post.props.override_username : '';
|
||||
const isEmoji = typeof overrideIconEmoji == 'string' && overrideIconEmoji !== '';
|
||||
const overrideIconEmoji = ensureString(post.props.override_icon_emoji);
|
||||
const overwriteName = ensureString(post.props?.override_username);
|
||||
const isEmoji = overrideIconEmoji !== '';
|
||||
const status = this.getStatus(fromAutoResponder, fromWebhook, user);
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {IntlShape, MessageDescriptor} from 'react-intl';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import {Posts} from 'mattermost-redux/constants';
|
||||
import type {MessageData} from 'mattermost-redux/utils/post_list';
|
||||
|
||||
import Markdown from 'components/markdown';
|
||||
|
||||
@@ -189,11 +190,7 @@ export type Props = {
|
||||
currentUserId: string;
|
||||
currentUsername: string;
|
||||
intl: IntlShape;
|
||||
messageData: Array<{
|
||||
actorId?: string;
|
||||
postType: string;
|
||||
userIds: string[];
|
||||
}>;
|
||||
messageData: MessageData[];
|
||||
showJoinLeave: boolean;
|
||||
userProfiles: UserProfile[];
|
||||
actions: {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import React, {memo} from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import {isMessageAttachmentArray} from '@mattermost/types/message_attachments';
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
import type {UserProfile as UserProfileType} from '@mattermost/types/users';
|
||||
|
||||
@@ -29,7 +30,7 @@ function CommentedOn({post, parentPostUser, onCommentClick}: Props) {
|
||||
message = (
|
||||
<CommentedOnFilesMessage parentPostId={post.id}/>
|
||||
);
|
||||
} else if (post.props?.attachments?.length > 0) {
|
||||
} else if (isMessageAttachmentArray(post.props?.attachments) && post.props.attachments.length > 0) {
|
||||
const attachment = post.props.attachments[0];
|
||||
const webhookMessage = attachment.pretext || attachment.title || attachment.text || attachment.fallback || '';
|
||||
message = Utils.replaceHtmlEntities(webhookMessage);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {FormattedMessage, useIntl} from 'react-intl';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
import {isStringArray} from '@mattermost/types/utilities';
|
||||
|
||||
import {getMissingProfilesByIds} from 'mattermost-redux/actions/users';
|
||||
import {makeGetProfilesByIdsAndUsernames} from 'mattermost-redux/selectors/entities/users';
|
||||
@@ -19,7 +20,7 @@ export type Props = {
|
||||
}
|
||||
function GMConversionMessage(props: Props): JSX.Element {
|
||||
const convertedByUserId = props.post.props.convertedByUserId;
|
||||
const gmMembersDuringConversionIDs = props.post.props.gmMembersDuringConversionIDs as string[];
|
||||
const gmMembersDuringConversionIDs = isStringArray(props.post.props.gmMembersDuringConversionIDs) ? props.post.props.gmMembersDuringConversionIDs : [];
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {KeyboardEvent, MouseEvent, CSSProperties} from 'react';
|
||||
import type {PostAction, PostActionOption} from '@mattermost/types/integration_actions';
|
||||
import type {
|
||||
MessageAttachment as MessageAttachmentType,
|
||||
MessageAttachmentField,
|
||||
} from '@mattermost/types/message_attachments';
|
||||
import type {PostImage} from '@mattermost/types/posts';
|
||||
|
||||
@@ -99,6 +98,9 @@ export default class MessageAttachment extends React.PureComponent<Props, State>
|
||||
|
||||
handleHeightReceivedForThumbUrl = ({height}: {height: number}) => {
|
||||
const {attachment} = this.props;
|
||||
if (!attachment.thumb_url) {
|
||||
return;
|
||||
}
|
||||
if (!this.props.imagesMetadata || (this.props.imagesMetadata && !this.props.imagesMetadata[attachment.thumb_url])) {
|
||||
this.handleHeightReceived(height);
|
||||
}
|
||||
@@ -106,6 +108,9 @@ export default class MessageAttachment extends React.PureComponent<Props, State>
|
||||
|
||||
handleHeightReceivedForImageUrl = ({height}: {height: number}) => {
|
||||
const {attachment} = this.props;
|
||||
if (!attachment.image_url) {
|
||||
return;
|
||||
}
|
||||
if (!this.props.imagesMetadata || (this.props.imagesMetadata && !this.props.imagesMetadata[attachment.image_url])) {
|
||||
this.handleHeightReceived(height);
|
||||
}
|
||||
@@ -234,7 +239,7 @@ export default class MessageAttachment extends React.PureComponent<Props, State>
|
||||
let nrTables = 0;
|
||||
const markdown = {markdown: false, mentionHighlight: false, atMentions: false};
|
||||
|
||||
fields.forEach((field: MessageAttachmentField, i: number) => {
|
||||
fields.forEach((field, i) => {
|
||||
if (rowPos === 2 || !(field.short === true) || lastWasLong) {
|
||||
fieldTables.push(
|
||||
<table
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type {AppBinding} from '@mattermost/types/apps';
|
||||
import {isAppBinding, type AppBinding} from '@mattermost/types/apps';
|
||||
import {isMessageAttachmentArray} from '@mattermost/types/message_attachments';
|
||||
import type {Post, PostEmbed} from '@mattermost/types/posts';
|
||||
import {isArrayOf} from '@mattermost/types/utilities';
|
||||
|
||||
import {validateBindings} from 'mattermost-redux/utils/apps';
|
||||
import {getEmbedFromMetadata} from 'mattermost-redux/utils/post_utils';
|
||||
|
||||
import MessageAttachmentList from 'components/post_view/message_attachments/message_attachment_list';
|
||||
@@ -83,10 +86,7 @@ export default class PostBodyAdditionalContent extends React.PureComponent<Props
|
||||
);
|
||||
|
||||
case 'message_attachment': {
|
||||
let attachments = [];
|
||||
if (this.props.post.props && this.props.post.props.attachments) {
|
||||
attachments = this.props.post.props.attachments;
|
||||
}
|
||||
const attachments = isMessageAttachmentArray(this.props.post.props?.attachments) ? this.props.post.props?.attachments : [];
|
||||
|
||||
return (
|
||||
<MessageAttachmentList
|
||||
@@ -153,13 +153,14 @@ export default class PostBodyAdditionalContent extends React.PureComponent<Props
|
||||
const embed = this.getEmbed();
|
||||
|
||||
if (this.props.appsEnabled) {
|
||||
if (hasValidEmbeddedBinding(this.props.post.props)) {
|
||||
const appEmbeds = isArrayOf<AppBinding>(this.props.post.props?.app_bindings, isAppBinding) ? validateBindings(this.props.post.props?.app_bindings) : [];
|
||||
if (appEmbeds.length) {
|
||||
// TODO Put some log / message if the form is not valid?
|
||||
return (
|
||||
<>
|
||||
{this.props.children}
|
||||
<EmbeddedBindings
|
||||
embeds={this.props.post.props.app_bindings}
|
||||
embeds={appEmbeds}
|
||||
post={this.props.post}
|
||||
/>
|
||||
</>
|
||||
@@ -184,21 +185,3 @@ export default class PostBodyAdditionalContent extends React.PureComponent<Props
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function hasValidEmbeddedBinding(props: Record<string, any>) {
|
||||
if (!props) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!props.app_bindings) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const embeds = props.app_bindings as AppBinding[];
|
||||
|
||||
if (!embeds.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {Post} from '@mattermost/types/posts';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import {General} from 'mattermost-redux/constants';
|
||||
import {ensureString} from 'mattermost-redux/utils/post_utils';
|
||||
|
||||
import FileAttachmentListContainer from 'components/file_attachment_list';
|
||||
import PriorityLabel from 'components/post_priority/post_priority_label';
|
||||
@@ -55,12 +56,8 @@ const PostMessagePreview = (props: Props) => {
|
||||
const getPostIconURL = (defaultURL: string, fromAutoResponder: boolean, fromWebhook: boolean): string => {
|
||||
const {enablePostIconOverride, hasImageProxy, previewPost} = props;
|
||||
const postProps = previewPost?.props;
|
||||
let postIconOverrideURL = '';
|
||||
let useUserIcon = '';
|
||||
if (postProps) {
|
||||
postIconOverrideURL = postProps.override_icon_url;
|
||||
useUserIcon = postProps.use_user_icon;
|
||||
}
|
||||
const postIconOverrideURL = ensureString(postProps?.override_icon_url);
|
||||
const useUserIcon = ensureString(postProps?.use_user_icon);
|
||||
|
||||
if (!fromAutoResponder && fromWebhook && !useUserIcon && enablePostIconOverride) {
|
||||
if (postIconOverrideURL && postIconOverrideURL !== '') {
|
||||
@@ -156,6 +153,8 @@ const PostMessagePreview = (props: Props) => {
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const overwriteName = ensureString(previewPost.props?.override_username);
|
||||
|
||||
return (
|
||||
<PostAttachmentContainer
|
||||
className='permalink'
|
||||
@@ -175,7 +174,7 @@ const PostMessagePreview = (props: Props) => {
|
||||
<UserProfileComponent
|
||||
userId={user?.id ?? ''}
|
||||
disablePopover={true}
|
||||
overwriteName={previewPost.props?.override_username || ''}
|
||||
overwriteName={overwriteName}
|
||||
/>
|
||||
</div>
|
||||
<div className='col d-flex align-items-center'>
|
||||
|
||||
@@ -115,7 +115,7 @@ export default class PostMessageView extends React.PureComponent<Props, State> {
|
||||
return <span>{post.message}</span>;
|
||||
}
|
||||
|
||||
const postType = post.props && post.props.type ? post.props.type : post.type;
|
||||
const postType = typeof post.props?.type === 'string' ? post.props.type : post.type;
|
||||
|
||||
if (pluginPostTypes && pluginPostTypes.hasOwnProperty(postType)) {
|
||||
const PluginComponent = pluginPostTypes[postType].component;
|
||||
|
||||
@@ -10,6 +10,8 @@ import {Link} from 'react-router-dom';
|
||||
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
|
||||
import {ensureString} from 'mattermost-redux/utils/post_utils';
|
||||
|
||||
import {emitCloseRightHandSide} from 'actions/global_actions';
|
||||
|
||||
import Markdown from 'components/markdown';
|
||||
@@ -126,9 +128,10 @@ export default class RhsCard extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
const message = ensureString(selected.props?.card);
|
||||
content = (
|
||||
<div className='info-card'>
|
||||
<Markdown message={(selected.props && selected.props.card) || ''}/>
|
||||
<Markdown message={message}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -140,13 +143,14 @@ export default class RhsCard extends React.Component<Props, State> {
|
||||
disablePopover={true}
|
||||
/>
|
||||
);
|
||||
if (selected.props.override_username && this.props.enablePostUsernameOverride) {
|
||||
const overrideUsername = ensureString(selected.props.override_username);
|
||||
if (overrideUsername && this.props.enablePostUsernameOverride) {
|
||||
user = (
|
||||
<UserProfile
|
||||
userId={selected.user_id}
|
||||
hideStatus={true}
|
||||
disablePopover={true}
|
||||
overwriteName={selected.props.override_username}
|
||||
overwriteName={overrideUsername}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ import {stripMarkdown} from 'utils/markdown';
|
||||
import './attachment_card.scss';
|
||||
|
||||
type Props = {
|
||||
fallback: string;
|
||||
pretext: string;
|
||||
title: string;
|
||||
text: string;
|
||||
author_name: string;
|
||||
fallback?: string;
|
||||
pretext?: string;
|
||||
title?: string;
|
||||
text?: string;
|
||||
author_name?: string;
|
||||
}
|
||||
|
||||
function AttachmentCard({
|
||||
@@ -28,7 +28,7 @@ function AttachmentCard({
|
||||
{`${authorName}: ${title}`}
|
||||
</div>
|
||||
<div className='attachment__truncated'>
|
||||
{stripMarkdown(text || pretext || fallback)}
|
||||
{stripMarkdown(text || pretext || fallback || '')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {isMessageAttachmentArray} from '@mattermost/types/message_attachments';
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
|
||||
import AttachmentCard from './attachment_card';
|
||||
@@ -17,7 +18,7 @@ function Attachment({post}: Props) {
|
||||
return <FileCard id={post.file_ids[0]}/>;
|
||||
}
|
||||
|
||||
if (post.props.attachments && post.props.attachments.length) {
|
||||
if (isMessageAttachmentArray(post.props.attachments) && post.props.attachments.length) {
|
||||
return <AttachmentCard {...post.props.attachments[0]}/>;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {getMissingProfilesByIds} from 'mattermost-redux/actions/users';
|
||||
import {Posts} from 'mattermost-redux/constants';
|
||||
import {getInt} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
|
||||
import {ensureString} from 'mattermost-redux/utils/post_utils';
|
||||
|
||||
import {manuallyMarkThreadAsUnread} from 'actions/views/threads';
|
||||
import {getIsMobileView} from 'selectors/views/browser';
|
||||
@@ -89,7 +90,7 @@ function ThreadItem({
|
||||
const tipStep = useSelector((state: GlobalState) => getInt(state, Preferences.CRT_TUTORIAL_STEP, currentUserId));
|
||||
const showListTutorialTip = tipStep === CrtTutorialSteps.LIST_POPOVER;
|
||||
const msgDeleted = formatMessage({id: 'post_body.deleted', defaultMessage: '(message deleted)'});
|
||||
const postAuthor = post.props?.override_username || displayName;
|
||||
const postAuthor = ensureString(post.props?.override_username) || displayName;
|
||||
|
||||
useEffect(() => {
|
||||
if (channel?.teammate_id) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {Team} from '@mattermost/types/teams';
|
||||
|
||||
import {basicUnreadMeta} from 'mattermost-redux/selectors/entities/channels';
|
||||
import type {BasicUnreadStatus} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {ensureString} from 'mattermost-redux/utils/post_utils';
|
||||
|
||||
import faviconDefault16x16 from 'images/favicon/favicon-default-16x16.png';
|
||||
import faviconDefault24x24 from 'images/favicon/favicon-default-24x24.png';
|
||||
@@ -146,7 +147,7 @@ export class UnreadsStatusHandlerClass extends React.PureComponent<Props> {
|
||||
const link64x64 = document.querySelector<HTMLLinkElement>('link[rel="icon"][sizes="64x64"]');
|
||||
const link96x96 = document.querySelector<HTMLLinkElement>('link[rel="icon"][sizes="96x96"]');
|
||||
|
||||
const getFavicon = (url: string): string => (typeof url === 'string' ? url : '');
|
||||
const getFavicon = (url: string): string => ensureString(url);
|
||||
|
||||
switch (badgeStatus) {
|
||||
case BadgeStatus.Mention: {
|
||||
|
||||
@@ -4724,10 +4724,9 @@
|
||||
"posts_view.loadMore": "Load More messages",
|
||||
"posts_view.newMsg": "New Messages",
|
||||
"postypes.custom_open_plugin_install_post_rendered.app_installation_request_text": "You’ve received the following app installation requests:",
|
||||
"postypes.custom_open_plugin_install_post_rendered.plugin_instructions": "<pluginApp></pluginApp> or visit the <marketplaceLink>Marketplace</marketplaceLink> to view all plugins.",
|
||||
"postypes.custom_open_plugin_install_post_rendered.plugin_instructions": "<pluginApp></pluginApp> or visit <marketplaceLink>Marketplace</marketplaceLink> to view all plugins.",
|
||||
"postypes.custom_open_plugin_install_post_rendered.plugin_request": "requested installing the {pluginRequests} app.",
|
||||
"postypes.custom_open_plugin_install_post_rendered.plugins_installed": "{pluginName} is now installed.",
|
||||
"postypes.custom_open_plugin_install_post_rendered.plugins_instructions": "Install the apps or visit the <marketplaceLink>Marketplace</marketplaceLink> to view all plugins.",
|
||||
"postypes.custom_open_plugin_install_post_rendered.plugins_instructions": "Install the apps or visit <marketplaceLink>Marketplace</marketplaceLink> to view all plugins.",
|
||||
"postypes.custom_open_pricing_modal_post_renderer.and": "and",
|
||||
"postypes.custom_open_pricing_modal_post_renderer.members": "{members} members",
|
||||
"postypes.custom_open_pricing_modal_post_renderer.membersThatRequested": "Members that requested ",
|
||||
|
||||
@@ -659,13 +659,13 @@ describe('Actions.Posts', () => {
|
||||
{
|
||||
fields: [
|
||||
{title: '@bbb', value: '@ccc'},
|
||||
{value: '@ddd'},
|
||||
{title: 'some title', value: '@ddd'},
|
||||
],
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{title: '@eee', value: '@fff'},
|
||||
{value: '@ggg'},
|
||||
{title: 'some other title', value: '@ggg'},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -7,6 +7,7 @@ import {batchActions} from 'redux-batched-actions';
|
||||
import type {Channel, ChannelUnread} from '@mattermost/types/channels';
|
||||
import type {FetchPaginatedThreadOptions} from '@mattermost/types/client4';
|
||||
import type {Group} from '@mattermost/types/groups';
|
||||
import {isMessageAttachmentArray} from '@mattermost/types/message_attachments';
|
||||
import type {Post, PostList, PostAcknowledgement} from '@mattermost/types/posts';
|
||||
import type {Reaction} from '@mattermost/types/reactions';
|
||||
import type {GlobalState} from '@mattermost/types/store';
|
||||
@@ -1091,7 +1092,7 @@ export function getNeededAtMentionedUsernamesAndGroups(state: GlobalState, posts
|
||||
// These correspond to the fields searched by getMentionsEnabledFields on the server
|
||||
findNeededUsernamesAndGroups(post.message);
|
||||
|
||||
if (post.props?.attachments) {
|
||||
if (isMessageAttachmentArray(post.props?.attachments)) {
|
||||
for (const attachment of post.props.attachments) {
|
||||
findNeededUsernamesAndGroups(attachment.pretext);
|
||||
findNeededUsernamesAndGroups(attachment.text);
|
||||
|
||||
@@ -1079,7 +1079,7 @@ describe('Selectors.Posts', () => {
|
||||
e: {
|
||||
...modifiedState.entities.posts.posts.e,
|
||||
props: {
|
||||
from_webhook: true,
|
||||
from_webhook: 'true',
|
||||
},
|
||||
user_id: user1.id,
|
||||
},
|
||||
|
||||
@@ -1436,7 +1436,7 @@ describe('extractUserActivityData', () => {
|
||||
|
||||
describe('combineUserActivityData', () => {
|
||||
it('combineUserActivitySystemPost returns null when systemPosts is an empty array', () => {
|
||||
expect(combineUserActivitySystemPost([])).toBeNull();
|
||||
expect(combineUserActivitySystemPost([])).toBeFalsy();
|
||||
});
|
||||
it('correctly combine different post types and actorIds by order', () => {
|
||||
const postAddToChannel1 = TestHelper.getPostMock({type: PostTypes.ADD_TO_CHANNEL, user_id: 'user_id_1', props: {addedUserId: 'added_user_id_1', addedUsername: 'added_username_1'}});
|
||||
|
||||
@@ -5,6 +5,7 @@ import moment from 'moment-timezone';
|
||||
|
||||
import type {ActivityEntry, Post} from '@mattermost/types/posts';
|
||||
import type {GlobalState} from '@mattermost/types/store';
|
||||
import {isStringArray, isArrayOf} from '@mattermost/types/utilities';
|
||||
|
||||
import {Posts} from 'mattermost-redux/constants';
|
||||
import {createSelector} from 'mattermost-redux/selectors/create_selector';
|
||||
@@ -13,7 +14,7 @@ import type {UserActivityPost} from 'mattermost-redux/selectors/entities/posts';
|
||||
import {shouldShowJoinLeaveMessages} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
|
||||
import {createIdsSelector, memoizeResult} from 'mattermost-redux/utils/helpers';
|
||||
import {isUserActivityPost, shouldFilterJoinLeavePost, isFromWebhook} from 'mattermost-redux/utils/post_utils';
|
||||
import {isUserActivityPost, shouldFilterJoinLeavePost, isFromWebhook, ensureString} from 'mattermost-redux/utils/post_utils';
|
||||
import {getUserCurrentTimezone} from 'mattermost-redux/utils/timezone_utils';
|
||||
|
||||
export const COMBINED_USER_ACTIVITY = 'user-activity-';
|
||||
@@ -320,7 +321,7 @@ export function makeGenerateCombinedPost(): (state: GlobalState, combinedId: str
|
||||
}
|
||||
|
||||
export function extractUserActivityData(userActivities: ActivityEntry[]) {
|
||||
const messageData: any[] = [];
|
||||
const messageData: MessageData[] = [];
|
||||
const allUserIds: string[] = [];
|
||||
const allUsernames: string[] = [];
|
||||
userActivities.forEach((activity) => {
|
||||
@@ -390,9 +391,9 @@ function isSameActorsInUserActivities(prevActivity: ActivityEntry, curActivity:
|
||||
});
|
||||
return hasAllActors;
|
||||
}
|
||||
export function combineUserActivitySystemPost(systemPosts: Post[] = []) {
|
||||
export function combineUserActivitySystemPost(systemPosts: Post[] = []): UserActivityProp | undefined {
|
||||
if (systemPosts.length === 0) {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
const userActivities: ActivityEntry[] = [];
|
||||
systemPosts.reverse().forEach((post: Post) => {
|
||||
@@ -402,8 +403,12 @@ export function combineUserActivitySystemPost(systemPosts: Post[] = []) {
|
||||
// When combining removed posts, the actorId does not need to be the same for each post.
|
||||
// All removed posts will be combined regardless of their respective actorIds.
|
||||
const isRemovedPost = post.type === Posts.POST_TYPES.REMOVE_FROM_CHANNEL;
|
||||
const userId = isUsersRelatedPost(postType) ? post.props.addedUserId || post.props.removedUserId : '';
|
||||
const username = isUsersRelatedPost(postType) ? post.props.addedUsername || post.props.removedUsername : '';
|
||||
const addedUserId = ensureString(post.props?.addedUserId);
|
||||
const removedUserId = ensureString(post.props?.removedUserId);
|
||||
const addedUsername = ensureString(post.props?.addedUsername);
|
||||
const removedUsername = ensureString(post.props?.removedUsername);
|
||||
const userId = isUsersRelatedPost(postType) ? addedUserId || removedUserId : '';
|
||||
const username = isUsersRelatedPost(postType) ? addedUsername || removedUsername : '';
|
||||
const prevPost = userActivities[userActivities.length - 1];
|
||||
const isSamePostType = prevPost && prevPost.postType === post.type;
|
||||
const isSameActor = prevPost && prevPost.actorId[0] === post.user_id;
|
||||
@@ -439,3 +444,55 @@ export function combineUserActivitySystemPost(systemPosts: Post[] = []) {
|
||||
|
||||
return extractUserActivityData(userActivities);
|
||||
}
|
||||
|
||||
export type MessageData = {
|
||||
actorId?: string;
|
||||
postType: string;
|
||||
userIds: string[];
|
||||
}
|
||||
|
||||
function isMessageData(v: unknown): v is MessageData {
|
||||
if (typeof v !== 'object' || !v) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('actorId' in v && typeof v.actorId !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!('postType' in v) || typeof v.postType !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!('userIds' in v) || !isStringArray(v.userIds)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
type UserActivityProp = {
|
||||
allUserIds: string[];
|
||||
allUsernames: string[];
|
||||
messageData: MessageData[];
|
||||
}
|
||||
|
||||
export function isUserActivityProp(v: unknown): v is UserActivityProp {
|
||||
if (typeof v !== 'object' || !v) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!('allUserIds' in v) || !isStringArray(v.allUserIds)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!('allUsernames' in v) || !isStringArray(v.allUsernames)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!('messageData' in v) || !isArrayOf(v.messageData, isMessageData)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -448,7 +448,7 @@ describe('PostUtils', () => {
|
||||
const post = TestHelper.getPostMock({
|
||||
user_id: 'currentUser',
|
||||
props: {
|
||||
from_webhook: true,
|
||||
from_webhook: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export function isMeMessage(post: Post): boolean {
|
||||
}
|
||||
|
||||
export function isFromWebhook(post: Post): boolean {
|
||||
return post.props && post.props.from_webhook;
|
||||
return post.props?.from_webhook === 'true';
|
||||
}
|
||||
|
||||
export function isPostEphemeral(post: Post): boolean {
|
||||
@@ -28,8 +28,8 @@ export function isPostEphemeral(post: Post): boolean {
|
||||
}
|
||||
|
||||
export function isUserAddedInChannel(post: Post, userId?: UserProfile['id']): boolean {
|
||||
const postTypeCheck = post.type && (post.type === Posts.POST_TYPES.ADD_TO_CHANNEL);
|
||||
const userIdCheck = post.props && post.props.addedUserId && (post.props.addedUserId === userId);
|
||||
const postTypeCheck = Boolean(post.type && (post.type === Posts.POST_TYPES.ADD_TO_CHANNEL));
|
||||
const userIdCheck = Boolean(post.props && post.props.addedUserId && (post.props.addedUserId === userId));
|
||||
return postTypeCheck && userIdCheck;
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ export function isPostCommentMention({post, currentUser, threadRepliedToByCurren
|
||||
commentsNotifyLevel = currentUser.notify_props.comments;
|
||||
}
|
||||
|
||||
const notCurrentUser = post.user_id !== currentUser.id || (post.props && post.props.from_webhook);
|
||||
const notCurrentUser = post.user_id !== currentUser.id || isFromWebhook(post);
|
||||
if (notCurrentUser) {
|
||||
if (commentsNotifyLevel === Preferences.COMMENTS_ANY && (threadCreatedByCurrentUser || threadRepliedToByCurrentUser)) {
|
||||
isCommentMention = true;
|
||||
@@ -241,3 +241,11 @@ export function shouldUpdatePost(receivedPost: Post, storedPost?: Post): boolean
|
||||
// The stored post is older than the one we've received
|
||||
return true;
|
||||
}
|
||||
|
||||
export function ensureString(v: unknown) {
|
||||
return typeof v === 'string' ? v : '';
|
||||
}
|
||||
|
||||
export function ensureNumber(v: unknown) {
|
||||
return typeof v === 'number' ? v : 0;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {Channel} from '@mattermost/types/channels';
|
||||
import type {ClientConfig, ClientLicense} from '@mattermost/types/config';
|
||||
import type {ServerError} from '@mattermost/types/errors';
|
||||
import type {Group} from '@mattermost/types/groups';
|
||||
import {isMessageAttachmentArray} from '@mattermost/types/message_attachments';
|
||||
import type {Post, PostPriorityMetadata} from '@mattermost/types/posts';
|
||||
import {PostPriority} from '@mattermost/types/posts';
|
||||
import type {Reaction} from '@mattermost/types/reactions';
|
||||
@@ -59,7 +60,7 @@ export function fromAutoResponder(post: Post): boolean {
|
||||
}
|
||||
|
||||
export function isFromWebhook(post: Post): boolean {
|
||||
return post.props && post.props.from_webhook === 'true';
|
||||
return post.props?.from_webhook === 'true';
|
||||
}
|
||||
|
||||
export function isFromBot(post: Post): boolean {
|
||||
@@ -545,7 +546,7 @@ export function createAriaLabelForPost(post: Post, author: string, isFlagged: bo
|
||||
}
|
||||
|
||||
let attachmentCount = 0;
|
||||
if (post.props && post.props.attachments) {
|
||||
if (isMessageAttachmentArray(post.props?.attachments)) {
|
||||
attachmentCount += post.props.attachments.length;
|
||||
}
|
||||
if (post.file_ids) {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
import emojiRegex from 'emoji-regex';
|
||||
|
||||
import type {DeepPartial} from '@mattermost/types/utilities';
|
||||
|
||||
import {getEmojiMap} from 'selectors/emojis';
|
||||
import store from 'stores/redux_store';
|
||||
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
isFormatTokenLimitError,
|
||||
doFormatText,
|
||||
replaceTokens,
|
||||
isChannelNamesMap,
|
||||
} from 'utils/text_formatting';
|
||||
import type {ChannelNamesMap} from 'utils/text_formatting';
|
||||
|
||||
@@ -576,3 +579,37 @@ describe('replaceTokens', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isChannelsNameMap', () => {
|
||||
it('happy path', () => {
|
||||
const prop: ChannelNamesMap = {
|
||||
'some id': {
|
||||
display_name: 'some name',
|
||||
team_name: 'some team name',
|
||||
},
|
||||
'some other id': 'simple string',
|
||||
'without team name': {display_name: 'some other name'},
|
||||
};
|
||||
expect(isChannelNamesMap(prop)).toBe(true);
|
||||
});
|
||||
|
||||
it('common false cases', () => {
|
||||
expect(isChannelNamesMap('')).toBe(false);
|
||||
expect(isChannelNamesMap(undefined)).toBe(false);
|
||||
expect(isChannelNamesMap(true)).toBe(false);
|
||||
expect(isChannelNamesMap(1)).toBe(false);
|
||||
});
|
||||
|
||||
it('display names are required', () => {
|
||||
const prop: DeepPartial<ChannelNamesMap> = {
|
||||
'some id': {
|
||||
display_name: 'some name',
|
||||
team_name: 'some team name',
|
||||
},
|
||||
};
|
||||
expect(isChannelNamesMap(prop)).toBe(true);
|
||||
|
||||
delete (prop['some id'] as any).display_name;
|
||||
expect(isChannelNamesMap(prop)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import emojiRegex from 'emoji-regex';
|
||||
import type {Renderer} from 'marked';
|
||||
|
||||
import type {SystemEmoji} from '@mattermost/types/emojis';
|
||||
import {isRecordOf} from '@mattermost/types/utilities';
|
||||
|
||||
import type {HighlightWithoutNotificationKey} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
@@ -37,6 +38,28 @@ export type ChannelNamesMap = {
|
||||
} | string;
|
||||
};
|
||||
|
||||
export function isChannelNamesMap(v: unknown): v is ChannelNamesMap {
|
||||
return isRecordOf(v, (e) => {
|
||||
if (typeof e === 'string') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof e !== 'object' || !e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!('display_name' in e) || typeof e.display_name !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('team_name' in e && typeof e.team_name !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export type SearchPattern = {
|
||||
pattern: RegExp;
|
||||
term: string;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {ProductScope} from './products';
|
||||
import {isProductScope, type ProductScope} from './products';
|
||||
import {isArrayOf, isStringArray} from './utilities';
|
||||
|
||||
export enum Permission {
|
||||
UserJoinedChannelNotification = 'user_joined_channel_notification',
|
||||
@@ -82,6 +83,72 @@ export type AppBinding = {
|
||||
submit?: AppCall;
|
||||
};
|
||||
|
||||
export function isAppBinding(obj: unknown): obj is AppBinding {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const binding = obj as AppBinding;
|
||||
|
||||
if (typeof binding.app_id !== 'string' || typeof binding.label !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (binding.location !== undefined && typeof binding.location !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (binding.supported_product_ids !== undefined && !isProductScope(binding.supported_product_ids)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (binding.icon !== undefined && typeof binding.icon !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (binding.hint !== undefined && typeof binding.hint !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (binding.description !== undefined && typeof binding.description !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (binding.role_id !== undefined && typeof binding.role_id !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (binding.depends_on_team !== undefined && typeof binding.depends_on_team !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (binding.depends_on_channel !== undefined && typeof binding.depends_on_channel !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (binding.depends_on_user !== undefined && typeof binding.depends_on_user !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (binding.depends_on_post !== undefined && typeof binding.depends_on_post !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (binding.bindings !== undefined && !isArrayOf(binding.bindings, isAppBinding)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (binding.form !== undefined && !isAppForm(binding.form)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (binding.submit !== undefined && !isAppCall(binding.submit)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export type AppCallValues = {
|
||||
[name: string]: any;
|
||||
};
|
||||
@@ -92,6 +159,26 @@ export type AppCall = {
|
||||
state?: any;
|
||||
};
|
||||
|
||||
function isAppCall(obj: unknown): obj is AppCall {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const call = obj as AppCall;
|
||||
|
||||
if (typeof call.path !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (call.expand !== undefined && !isAppExpand(call.expand)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Here we're assuming that 'state' can be of any type, so no type check for 'state'
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export type AppCallRequest = AppCall & {
|
||||
context: AppContext;
|
||||
values?: AppCallValues;
|
||||
@@ -159,6 +246,64 @@ export type AppExpand = {
|
||||
locale?: AppExpandLevel;
|
||||
};
|
||||
|
||||
function isAppExpand(v: unknown): v is AppExpand {
|
||||
if (typeof v !== 'object' || v === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expand = v as AppExpand;
|
||||
|
||||
if (expand.app !== undefined && typeof expand.app !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expand.acting_user !== undefined && typeof expand.acting_user !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expand.acting_user_access_token !== undefined && typeof expand.acting_user_access_token !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expand.channel !== undefined && typeof expand.channel !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expand.config !== undefined && typeof expand.config !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expand.mentioned !== undefined && typeof expand.mentioned !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expand.parent_post !== undefined && typeof expand.parent_post !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expand.post !== undefined && typeof expand.post !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expand.root_post !== undefined && typeof expand.root_post !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expand.team !== undefined && typeof expand.team !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expand.user !== undefined && typeof expand.user !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expand.locale !== undefined && typeof expand.locale !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export type AppForm = {
|
||||
title?: string;
|
||||
header?: string;
|
||||
@@ -183,7 +328,78 @@ export type AppForm = {
|
||||
depends_on?: string[];
|
||||
};
|
||||
|
||||
function isAppForm(v: unknown): v is AppForm {
|
||||
if (typeof v !== 'object' || v === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const form = v as AppForm;
|
||||
|
||||
if (form.title !== undefined && typeof form.title !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (form.header !== undefined && typeof form.header !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (form.footer !== undefined && typeof form.footer !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (form.icon !== undefined && typeof form.icon !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (form.submit_buttons !== undefined && typeof form.submit_buttons !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (form.cancel_button !== undefined && typeof form.cancel_button !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (form.submit_on_cancel !== undefined && typeof form.submit_on_cancel !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (form.fields !== undefined && !isArrayOf(form.fields, isAppField)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (form.source !== undefined && !isAppCall(form.source)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (form.submit !== undefined && !isAppCall(form.submit)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (form.depends_on !== undefined && !isStringArray(form.depends_on)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export type AppFormValue = string | AppSelectOption | boolean | null;
|
||||
|
||||
function isAppFormValue(v: unknown): v is AppFormValue {
|
||||
if (typeof v === 'string') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof v === 'boolean') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (v === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isAppSelectOption(v);
|
||||
}
|
||||
|
||||
export type AppFormValues = { [name: string]: AppFormValue };
|
||||
|
||||
export type AppSelectOption = {
|
||||
@@ -192,6 +408,24 @@ export type AppSelectOption = {
|
||||
icon_data?: string;
|
||||
};
|
||||
|
||||
function isAppSelectOption(v: unknown): v is AppSelectOption {
|
||||
if (typeof v !== 'object' || v === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const option = v as AppSelectOption;
|
||||
|
||||
if (typeof option.label !== 'string' || typeof option.value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (option.icon_data !== undefined && typeof option.icon_data !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export type AppFieldType = string;
|
||||
|
||||
// This should go in mattermost-redux
|
||||
@@ -226,6 +460,80 @@ export type AppField = {
|
||||
max_length?: number;
|
||||
};
|
||||
|
||||
function isAppField(v: unknown): v is AppField {
|
||||
if (typeof v !== 'object' || v === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const field = v as AppField;
|
||||
|
||||
if (typeof field.name !== 'string' || typeof field.type !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.is_required !== undefined && typeof field.is_required !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.readonly !== undefined && typeof field.readonly !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.value !== undefined && !isAppFormValue(field.value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.description !== undefined && typeof field.description !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.label !== undefined && typeof field.label !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.hint !== undefined && typeof field.hint !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.position !== undefined && typeof field.position !== 'number') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.modal_label !== undefined && typeof field.modal_label !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.refresh !== undefined && typeof field.refresh !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.options !== undefined && !isArrayOf(field.options, isAppSelectOption)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.multiselect !== undefined && typeof field.multiselect !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.lookup !== undefined && !isAppCall(field.lookup)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.subtype !== undefined && typeof field.subtype !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.min_length !== undefined && typeof field.min_length !== 'number') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.max_length !== undefined && typeof field.max_length !== 'number') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export type AutocompleteSuggestion = {
|
||||
suggestion: string;
|
||||
complete?: string;
|
||||
|
||||
@@ -1,27 +1,91 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {isArrayOf} from './utilities';
|
||||
|
||||
export type PostAction = {
|
||||
id?: string;
|
||||
id: string;
|
||||
type?: string;
|
||||
name?: string;
|
||||
name: string;
|
||||
disabled?: boolean;
|
||||
style?: string;
|
||||
data_source?: string;
|
||||
options?: PostActionOption[];
|
||||
default_option?: string;
|
||||
integration?: PostActionIntegration;
|
||||
cookie?: string;
|
||||
};
|
||||
|
||||
export function isPostAction(v: unknown): v is PostAction {
|
||||
if (typeof v !== 'object' || !v) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!('id' in v)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof v.id !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!('name' in v)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof v.name !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('type' in v && typeof v.type !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('disabled' in v && typeof v.disabled !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('style' in v && typeof v.style !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('data_source' in v && typeof v.data_source !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('options' in v && !isArrayOf(v.options, isPostActionOption)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('default_option' in v && typeof v.default_option !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('cookie' in v && typeof v.cookie !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export type PostActionOption = {
|
||||
text: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type PostActionIntegration = {
|
||||
url?: string;
|
||||
context?: Record<string, any>;
|
||||
function isPostActionOption(v: unknown): v is PostActionOption {
|
||||
if (typeof v !== 'object' || !v) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('text' in v && typeof v.text !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('value' in v && typeof v.value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export type PostActionResponse = {
|
||||
|
||||
@@ -1,30 +1,141 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {PostAction} from './integration_actions';
|
||||
import {isPostAction, type PostAction} from './integration_actions';
|
||||
import {isArrayOf} from './utilities';
|
||||
|
||||
export type MessageAttachment = {
|
||||
id: number;
|
||||
fallback: string;
|
||||
color: string;
|
||||
pretext: string;
|
||||
author_name: string;
|
||||
author_link: string;
|
||||
author_icon: string;
|
||||
title: string;
|
||||
title_link: string;
|
||||
text: string;
|
||||
fields: MessageAttachmentField[];
|
||||
image_url: string;
|
||||
thumb_url: string;
|
||||
footer: string;
|
||||
footer_icon: string;
|
||||
timestamp: number | string;
|
||||
fallback?: string;
|
||||
color?: string;
|
||||
pretext?: string;
|
||||
author_name?: string;
|
||||
author_link?: string;
|
||||
author_icon?: string;
|
||||
title?: string;
|
||||
title_link?: string;
|
||||
text?: string;
|
||||
fields?: MessageAttachmentField[] | null;
|
||||
image_url?: string;
|
||||
thumb_url?: string;
|
||||
footer?: string;
|
||||
footer_icon?: string;
|
||||
actions?: PostAction[];
|
||||
};
|
||||
|
||||
export function isMessageAttachmentArray(v: unknown): v is MessageAttachment[] {
|
||||
return isArrayOf(v, isMessageAttachment);
|
||||
}
|
||||
|
||||
function isMessageAttachment(v: unknown): v is MessageAttachment {
|
||||
if (typeof v !== 'object' || !v) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('fallback' in v && typeof v.fallback !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We may consider adding more validation to what color may be
|
||||
if ('color' in v && typeof v.color !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('pretext' in v && typeof v.pretext !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('author_name' in v && typeof v.author_name !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Where it is used, we are calling isUrlSafe. We could consider calling it here
|
||||
if ('author_link' in v && typeof v.author_link !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We may need more validation since this is going to be passed to an img src prop
|
||||
if ('author_icon' in v && typeof v.author_icon !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('title' in v && typeof v.title !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Where it is used, we are calling isUrlSafe. We could consider calling it here
|
||||
if ('title_link' in v && typeof v.title_link !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('text' in v && typeof v.text !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We may need more validation since this is going to be passed to an img src prop
|
||||
if ('image_url' in v && typeof v.image_url !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We may need more validation since this is going to be passed to an img src prop
|
||||
if ('thumb_url' in v && typeof v.thumb_url !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We are truncating if the size is more than some constant. We could check this here
|
||||
if ('footer' in v && typeof v.footer !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We may need more validation since this is going to be passed to an img src prop
|
||||
if ('footer_icon' in v && typeof v.footer_icon !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('fields' in v && v.fields !== null && !isArrayOf(v.fields, isMessageAttachmentField)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('actions' in v && !isArrayOf(v.actions, isPostAction)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export type MessageAttachmentField = {
|
||||
title: string;
|
||||
value: any;
|
||||
short: boolean;
|
||||
short?: boolean;
|
||||
}
|
||||
|
||||
function isMessageAttachmentField(v: unknown) {
|
||||
if (typeof v !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!v) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!('title' in v)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof v.title !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!('value' in v)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof v.value === 'object' && v.value && 'toString' in v.value && typeof v.value.toString !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('short' in v && typeof v.short !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -9,10 +9,10 @@ import type {FileInfo} from './files';
|
||||
import type {Reaction} from './reactions';
|
||||
import type {TeamType} from './teams';
|
||||
import type {UserProfile} from './users';
|
||||
import type {
|
||||
RelationOneToOne,
|
||||
RelationOneToMany,
|
||||
IDMappedObjects,
|
||||
import {
|
||||
type RelationOneToOne,
|
||||
type RelationOneToMany,
|
||||
type IDMappedObjects,
|
||||
} from './utilities';
|
||||
|
||||
export type PostType = 'system_add_remove' |
|
||||
@@ -86,7 +86,7 @@ export type Post = {
|
||||
original_id: string;
|
||||
message: string;
|
||||
type: PostType;
|
||||
props: Record<string, any>;
|
||||
props: Record<string, unknown>;
|
||||
hashtags: string;
|
||||
pending_post_id: string;
|
||||
reply_count: number;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {isArrayOf} from './utilities';
|
||||
|
||||
/**
|
||||
* - `null` - explicitly Channels
|
||||
* - `string` - uuid - any other product
|
||||
@@ -9,3 +11,11 @@ export type ProductIdentifier = null | string;
|
||||
|
||||
/** @see {@link ProductIdentifier} */
|
||||
export type ProductScope = ProductIdentifier | ProductIdentifier[];
|
||||
|
||||
export function isProductScope(v: unknown): v is ProductScope {
|
||||
if (v === null || typeof v === 'string') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isArrayOf(v, (e) => e === null || typeof v === 'string');
|
||||
}
|
||||
|
||||
@@ -45,3 +45,31 @@ export type Intersection<T1, T2> =
|
||||
Omit<Omit<T1&T2, keyof(Omit<T1, keyof(T2)>)>, keyof(Omit<T2, keyof(T1)>)>;
|
||||
|
||||
export type PartialExcept<T extends Record<string, unknown>, TKeysNotPartial extends keyof T> = Partial<T> & Pick<T, TKeysNotPartial>;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Ссылка в новой задаче
Block a user