Files
upstream-mattermost/webapp/channels/src/actions/post_actions.ts
upstream-mirror 240db4e2a7
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 00:29:55 +05:00

519 lines
19 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {AnyAction} from 'redux';
import {batchActions} from 'redux-batched-actions';
import type {WebSocketMessages} from '@mattermost/client';
import type {FileInfo} from '@mattermost/types/files';
import type {Post} from '@mattermost/types/posts';
import type {ScheduledPost} from '@mattermost/types/schedule_post';
import {SearchTypes} from 'mattermost-redux/action_types';
import {getMyChannelMember} from 'mattermost-redux/actions/channels';
import * as PostActions from 'mattermost-redux/actions/posts';
import {createSchedulePost} from 'mattermost-redux/actions/scheduled_posts';
import * as ThreadActions from 'mattermost-redux/actions/threads';
import {getChannel, getMyChannelMember as getMyChannelMemberSelector} from 'mattermost-redux/selectors/entities/channels';
import {makeGetFilesForPost} from 'mattermost-redux/selectors/entities/files';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import * as PostSelectors from 'mattermost-redux/selectors/entities/posts';
import {isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUserId, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
import {canEditPost, comparePosts} from 'mattermost-redux/utils/post_utils';
import {addRecentEmoji, addRecentEmojis} from 'actions/emoji_actions';
import {setGlobalItem} from 'actions/storage';
import * as StorageActions from 'actions/storage';
import {loadNewDMIfNeeded, loadNewGMIfNeeded} from 'actions/user_actions';
import {removeDraft} from 'actions/views/drafts';
import {closeModal, openModal} from 'actions/views/modals';
import * as RhsActions from 'actions/views/rhs';
import {manuallyMarkThreadAsUnread} from 'actions/views/threads';
import {getConnectionId} from 'selectors/general';
import {isEmbedVisible, isInlineImageVisible} from 'selectors/posts';
import {getSelectedPostId, getSelectedPostCardId, getRhsState} from 'selectors/rhs';
import {getGlobalItem} from 'selectors/storage';
import ReactionLimitReachedModal from 'components/reaction_limit_reached_modal';
import {
ActionTypes,
Constants,
ModalIdentifiers,
RHSStates,
StoragePrefixes,
} from 'utils/constants';
import {matchEmoticons} from 'utils/emoticons';
import {makeGetIsReactionAlreadyAddedToPost, makeGetUniqueEmojiNameReactionsForPost} from 'utils/post_utils';
import type {
GlobalState,
DispatchFunc,
ActionFunc,
ActionFuncAsync,
ThunkActionFunc,
} from 'types/store';
import type {PostDraft} from 'types/store/draft';
import type {StorageItem} from 'types/store/storage';
import {completePostReceive} from './new_post';
import type {OnSubmitOptions, SubmitPostReturnType} from './views/create_comment';
export type CreatePostOptions = {
keepDraft?: boolean;
ignorePostError?: boolean;
};
export function handleNewPost(post: Post, msg?: WebSocketMessages.Posted | WebSocketMessages.EphemeralPost): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
let websocketMessageProps = {};
const state = getState();
if (msg) {
websocketMessageProps = msg.data!;
}
const myChannelMember = getMyChannelMemberSelector(state, post.channel_id);
const myChannelMemberDoesntExist = !myChannelMember || (Object.keys(myChannelMember).length === 0 && myChannelMember.constructor === Object);
if (myChannelMemberDoesntExist) {
await dispatch(getMyChannelMember(post.channel_id));
}
dispatch(completePostReceive(post, websocketMessageProps, myChannelMemberDoesntExist));
if (msg && msg.data && 'channel_type' in msg.data) {
if (msg.data.channel_type === Constants.DM_CHANNEL) {
dispatch(loadNewDMIfNeeded(post.channel_id));
} else if (msg.data.channel_type === Constants.GM_CHANNEL) {
dispatch(loadNewGMIfNeeded(post.channel_id));
}
}
return {data: true};
};
}
const getPostsForIds = PostSelectors.makeGetPostsForIds();
export function flagPost(postId: string): ActionFuncAsync {
return async (dispatch, getState) => {
await dispatch(PostActions.flagPost(postId));
const state = getState();
const rhsState = getRhsState(state);
if (rhsState === RHSStates.FLAG) {
dispatch(addPostToSearchResults(postId));
}
return {data: true};
};
}
export function unflagPost(postId: string): ActionFuncAsync {
return async (dispatch, getState) => {
await dispatch(PostActions.unflagPost(postId));
const state = getState();
const rhsState = getRhsState(state);
if (rhsState === RHSStates.FLAG) {
removePostFromSearchResults(postId, state, dispatch);
}
return {data: true};
};
}
function addRecentEmojisForMessage(message: string): ActionFunc {
return (dispatch) => {
// parse message and emit emoji event
const emojis = matchEmoticons(message);
if (emojis) {
const trimmedEmojis = emojis.map((emoji) => emoji.substring(1, emoji.length - 1));
dispatch(addRecentEmojis(trimmedEmojis));
}
return {data: true};
};
}
export function createPost(
post: Post,
files: FileInfo[],
afterSubmit?: (response: SubmitPostReturnType) => void,
options?: OnSubmitOptions,
): ActionFuncAsync<PostActions.CreatePostReturnType> {
return async (dispatch) => {
dispatch(addRecentEmojisForMessage(post.message));
const result = await dispatch(PostActions.createPost(post, files, afterSubmit));
if (!options?.keepDraft) {
if (post.root_id) {
dispatch(storeCommentDraft(post.root_id, null));
} else {
dispatch(storeDraft(post.channel_id, null));
}
}
options?.afterOptimisticSubmit?.();
return result;
};
}
export function createSchedulePostFromDraft(scheduledPost: ScheduledPost): ActionFuncAsync<PostActions.CreatePostReturnType> {
return async (dispatch, getState) => {
dispatch(addRecentEmojisForMessage(scheduledPost.message));
const state = getState();
const connectionId = getConnectionId(state);
const channel = state.entities.channels.channels[scheduledPost.channel_id];
const result = await dispatch(createSchedulePost(scheduledPost, channel.team_id, connectionId));
return {
created: !result.error && result.data,
error: result.error,
};
};
}
function storeDraft(channelId: string, draft: null): ActionFunc {
return (dispatch) => {
dispatch(StorageActions.setGlobalItem('draft_' + channelId, draft));
return {data: true};
};
}
function storeCommentDraft(rootPostId: string, draft: null): ActionFunc {
return (dispatch) => {
dispatch(StorageActions.setGlobalItem('comment_draft_' + rootPostId, draft));
return {data: true};
};
}
export function submitReaction(postId: string, action: string, emojiName: string): ActionFuncAsync<PostActions.SubmitReactionReturnType> {
return async (dispatch, getState) => {
const state = getState();
const getIsReactionAlreadyAddedToPost = makeGetIsReactionAlreadyAddedToPost();
const isReactionAlreadyAddedToPost = getIsReactionAlreadyAddedToPost(state, postId, emojiName);
if (action === '+' && !isReactionAlreadyAddedToPost) {
return dispatch(addReaction(postId, emojiName));
} else if (action === '-' && isReactionAlreadyAddedToPost) {
return dispatch(PostActions.removeReaction(postId, emojiName));
}
return {error: new Error(`unknown action ${action}`)};
};
}
export function toggleReaction(postId: string, emojiName: string): ActionFuncAsync<PostActions.SubmitReactionReturnType> {
return async (dispatch, getState) => {
const state = getState();
const getIsReactionAlreadyAddedToPost = makeGetIsReactionAlreadyAddedToPost();
const isReactionAlreadyAddedToPost = getIsReactionAlreadyAddedToPost(state, postId, emojiName);
if (isReactionAlreadyAddedToPost) {
return dispatch(PostActions.removeReaction(postId, emojiName));
}
return dispatch(addReaction(postId, emojiName));
};
}
export function addReaction(postId: string, emojiName: string): ActionFuncAsync<PostActions.SubmitReactionReturnType> {
const getUniqueEmojiNameReactionsForPost = makeGetUniqueEmojiNameReactionsForPost();
return async (dispatch, getState) => {
const state = getState();
const config = getConfig(state);
const uniqueEmojiNames = getUniqueEmojiNameReactionsForPost(state, postId) ?? [];
// If we're adding a new reaction but we're already at or over the limit, stop
if (uniqueEmojiNames.length >= Number(config.UniqueEmojiReactionLimitPerPost) && !uniqueEmojiNames.some((name) => name === emojiName)) {
dispatch(openModal({
modalId: ModalIdentifiers.REACTION_LIMIT_REACHED,
dialogType: ReactionLimitReachedModal,
dialogProps: {
isAdmin: isCurrentUserSystemAdmin(state),
onExited: () => closeModal(ModalIdentifiers.REACTION_LIMIT_REACHED),
},
}));
return {error: new Error('reached reaction limit')};
}
dispatch(addRecentEmoji(emojiName));
const result = await dispatch(PostActions.addReaction(postId, emojiName));
return result;
};
}
export function searchForTerm(term: string): ActionFunc<boolean> {
return (dispatch) => {
dispatch(RhsActions.updateSearchTerms(term));
dispatch(RhsActions.showSearchResults());
return {data: true};
};
}
function addPostToSearchResults(postId: string): ActionFunc {
return (dispatch, getState) => {
const state = getState();
const results = state.entities.search.results;
const index = results.indexOf(postId);
if (index === -1) {
const newPost = PostSelectors.getPost(state, postId);
const posts = getPostsForIds(state, results).reduce((acc, post) => {
acc[post.id] = post;
return acc;
}, {} as Record<string, Post>);
posts[newPost.id] = newPost;
const newResults = [...results, postId];
newResults.sort((a, b) => comparePosts(posts[a], posts[b]));
dispatch({
type: SearchTypes.RECEIVED_SEARCH_POSTS,
data: {posts, order: newResults},
});
}
return {data: true};
};
}
function removePostFromSearchResults(postId: string, state: GlobalState, dispatch: DispatchFunc) {
let results = state.entities.search.results;
const index = results.indexOf(postId);
if (index > -1) {
results = [...results];
results.splice(index, 1);
const posts = getPostsForIds(state, results);
dispatch({
type: SearchTypes.RECEIVED_SEARCH_POSTS,
data: {posts, order: results},
});
}
}
export function pinPost(postId: string): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
await dispatch(PostActions.pinPost(postId));
const state = getState();
const rhsState = getRhsState(state);
if (rhsState === RHSStates.PIN) {
dispatch(addPostToSearchResults(postId));
}
return {data: true};
};
}
export function unpinPost(postId: string): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
await dispatch(PostActions.unpinPost(postId));
const state = getState();
const rhsState = getRhsState(state);
if (rhsState === RHSStates.PIN) {
removePostFromSearchResults(postId, state, dispatch);
}
return {data: true};
};
}
export function setEditingPost(postId = '', refocusId = '', isRHS = false): ActionFunc<boolean, GlobalState> {
const getFilesForPost = makeGetFilesForPost();
return (dispatch, getState) => {
const state = getState();
let post = PostSelectors.getPost(state, postId);
// getPost selectors doesn't include post's file metadata, so we need to add it manually
if (post.file_ids?.length) {
// if the post has files, get their metadata and insert it into the post object
const files = getFilesForPost(state, postId);
post = {...post, metadata: {...post.metadata, files}};
}
if (!post || post.pending_post_id === postId) {
return {data: false};
}
const config = state.entities.general.config;
const license = state.entities.general.license;
const userId = getCurrentUserId(state);
const channel = getChannel(state, post.channel_id);
const teamId = channel?.team_id || '';
const canEdit = canEditPost(state, config, license, teamId, post.channel_id, userId, post);
if (!canEdit) {
return {data: false};
}
const storageKey = `${StoragePrefixes.EDIT_DRAFT}${post.id}`;
const actions: AnyAction[] = [{
type: ActionTypes.TOGGLE_EDITING_POST,
data: {postId, refocusId, isRHS, show: true},
}];
// We need to see if post's draft is already in store, if it is, we don't need to set it again
const editDraftInStore = getGlobalItem(state, storageKey, null) as StorageItem<PostDraft>['value'] | null;
if (
!editDraftInStore ||
(editDraftInStore &&
editDraftInStore?.message?.length === 0 &&
editDraftInStore?.fileInfos?.length === 0 &&
editDraftInStore?.uploadsInProgress?.length === 0
)
) {
actions.push(setGlobalItem(storageKey, post));
}
dispatch(batchActions(actions));
return {data: true};
};
}
export function unsetEditingPost(): ActionFunc<boolean, GlobalState> {
return (dispatch, getState) => {
const editingPostId = getState().views.posts.editingPost.postId;
const actions: AnyAction[] = [{
type: ActionTypes.TOGGLE_EDITING_POST,
data: {
show: false,
},
}];
if (editingPostId) {
const storageKey = `${StoragePrefixes.EDIT_DRAFT}${editingPostId}`;
actions.push(StorageActions.removeGlobalItem(storageKey));
}
dispatch(batchActions(actions));
return {data: true};
};
}
export function markPostAsUnread(post: Post, location?: string): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
const userId = getCurrentUserId(state);
const currentTeamId = getCurrentTeamId(state);
// if CRT:ON and this is from within ThreadViewer (e.g. post dot-menu), mark the thread as unread and followed
if (isCollapsedThreadsEnabled(state) && (location === 'RHS_ROOT' || location === 'RHS_COMMENT')) {
const threadId = post.root_id || post.id;
ThreadActions.handleFollowChanged(dispatch, threadId, currentTeamId, true);
dispatch(manuallyMarkThreadAsUnread(threadId, post.create_at - 1));
await dispatch(ThreadActions.markThreadAsUnread(userId, currentTeamId, threadId, post.id));
} else {
// use normal channel unread system
await dispatch(PostActions.setUnreadPost(userId, post.id));
}
return {data: true};
};
}
export function markMostRecentPostInChannelAsUnread(channelId: string): ActionFuncAsync {
return async (dispatch, getState) => {
let state = getState();
let postId = PostSelectors.getMostRecentNonSystemPostIdInChannel(state, channelId);
if (!postId) {
await dispatch(PostActions.getPosts(channelId));
state = getState();
postId = PostSelectors.getMostRecentNonSystemPostIdInChannel(state, channelId);
}
if (postId) {
const lastPost = PostSelectors.getPost(state, postId);
dispatch(markPostAsUnread(lastPost, 'CENTER'));
}
return {data: true};
};
}
// Action called by DeletePostModal when the post is deleted
export function deleteAndRemovePost(post: Post): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
const {error} = await dispatch(PostActions.deletePost(post));
if (error) {
return {error};
}
if (post.id === getSelectedPostId(getState())) {
dispatch({
type: ActionTypes.SELECT_POST,
postId: '',
channelId: '',
timestamp: 0,
});
}
if (post.id === getSelectedPostCardId(getState())) {
dispatch({
type: ActionTypes.SELECT_POST_CARD,
postId: '',
channelId: '',
});
}
if (post.root_id === '') {
const key = StoragePrefixes.COMMENT_DRAFT + post.id;
if (getGlobalItem(getState(), key, null)) {
dispatch(removeDraft(key, post.channel_id, post.id));
}
}
dispatch(PostActions.removePost(post));
return {data: true};
};
}
export function toggleEmbedVisibility(postId: string): ThunkActionFunc<void> {
return (dispatch, getState) => {
const state = getState();
const currentUserId = getCurrentUserId(state);
const visible = isEmbedVisible(state, postId);
dispatch(StorageActions.setGlobalItem(StoragePrefixes.EMBED_VISIBLE + currentUserId + '_' + postId, !visible));
};
}
export function resetEmbedVisibility() {
return StorageActions.actionOnGlobalItemsWithPrefix(StoragePrefixes.EMBED_VISIBLE, () => null);
}
export function toggleInlineImageVisibility(postId: string, imageKey: string): ThunkActionFunc<void> {
return (dispatch, getState) => {
const state = getState();
const currentUserId = getCurrentUserId(state);
const visible = isInlineImageVisible(state, postId, imageKey);
dispatch(StorageActions.setGlobalItem(StoragePrefixes.INLINE_IMAGE_VISIBLE + currentUserId + '_' + postId + '_' + imageKey, !visible));
};
}
export function resetInlineImageVisibility() {
return StorageActions.actionOnGlobalItemsWithPrefix(StoragePrefixes.INLINE_IMAGE_VISIBLE, () => null);
}
/*
* It is called from either center or rhs text input when shortcut for react to last message is pressed
*
* @param {string} emittedFrom - It can be either "CENTER", "RHS_ROOT" or "NO_WHERE"
*/
export function emitShortcutReactToLastPostFrom(emittedFrom: keyof typeof Constants.Locations) {
return {
type: ActionTypes.EMITTED_SHORTCUT_REACT_TO_LAST_POST,
payload: emittedFrom,
};
}