UNPKG

mattermost-redux

Version:

Common code (API client, Redux stores, logic, utility functions) for building a Mattermost client

1,224 lines (1,033 loc) 40.2 kB
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {Client4, DEFAULT_LIMIT_AFTER, DEFAULT_LIMIT_BEFORE} from 'client'; import {General, Preferences, Posts} from '../constants'; import {PostTypes, ChannelTypes, FileTypes, IntegrationTypes} from 'action_types'; import {getMyChannelMember as getMyChannelMemberSelector} from 'selectors/entities/channels'; import {getCustomEmojisByName as selectCustomEmojisByName} from 'selectors/entities/emojis'; import {getConfig} from 'selectors/entities/general'; import * as Selectors from 'selectors/entities/posts'; import {getCurrentUserId, getUsersByUsername} from 'selectors/entities/users'; import {parseNeededCustomEmojisFromText} from 'utils/emoji_utils'; import {isCombinedUserActivityPost} from 'utils/post_list'; import {systemEmojis, getCustomEmojiByName, getCustomEmojisByName} from './emojis'; import {logError} from './errors'; import {bindClientFunc, forceLogoutIfNecessary} from './helpers'; import { deletePreferences, savePreferences, } from './preferences'; import {getProfilesByIds, getProfilesByUsernames, getStatusesByIds} from './users'; import {Action, ActionResult, batchActions, DispatchFunc, GetStateFunc} from 'types/actions'; import {ChannelUnread} from 'types/channels'; import {GlobalState} from 'types/store'; import {Post, PostList} from 'types/posts'; import {ServerError} from 'types/errors'; import {Reaction} from 'types/reactions'; import {UserProfile} from 'types/users'; import {Dictionary} from 'types/utilities'; import {CustomEmoji} from 'types/emojis'; // receivedPost should be dispatched after a single post from the server. This typically happens when an existing post // is updated. export function receivedPost(post: Post) { return { type: PostTypes.RECEIVED_POST, data: post, }; } // receivedNewPost should be dispatched when receiving a newly created post or when sending a request to the server // to make a new post. export function receivedNewPost(post: Post) { return { type: PostTypes.RECEIVED_NEW_POST, data: post, }; } // receivedPosts should be dispatched when receiving multiple posts from the server that may or may not be ordered. // This will typically be used alongside other actions like receivedPostsAfter which require the posts to be ordered. export function receivedPosts(posts: PostList) { return { type: PostTypes.RECEIVED_POSTS, data: posts, }; } // receivedPostsAfter should be dispatched when receiving an ordered list of posts that come before a given post. export function receivedPostsAfter(posts: PostList, channelId: string, afterPostId: string, recent = false) { return { type: PostTypes.RECEIVED_POSTS_AFTER, channelId, data: posts, afterPostId, recent, }; } // receivedPostsBefore should be dispatched when receiving an ordered list of posts that come after a given post. export function receivedPostsBefore(posts: PostList, channelId: string, beforePostId: string, oldest = false) { return { type: PostTypes.RECEIVED_POSTS_BEFORE, channelId, data: posts, beforePostId, oldest, }; } // receivedPostsSince should be dispatched when receiving a list of posts that have been updated since a certain time. // Due to how the API endpoint works, some of these posts will be ordered, but others will not, so this needs special // handling from the reducers. export function receivedPostsSince(posts: PostList, channelId: string) { return { type: PostTypes.RECEIVED_POSTS_SINCE, channelId, data: posts, }; } // receivedPostsInChannel should be dispatched when receiving a list of ordered posts within a channel when the // the adjacent posts are not known. export function receivedPostsInChannel(posts: PostList, channelId: string, recent = false, oldest = false) { return { type: PostTypes.RECEIVED_POSTS_IN_CHANNEL, channelId, data: posts, recent, oldest, }; } // receivedPostsInThread should be dispatched when receiving a list of unordered posts in a thread. export function receivedPostsInThread(posts: PostList, rootId: string) { return { type: PostTypes.RECEIVED_POSTS_IN_THREAD, data: posts, rootId, }; } // postDeleted should be dispatched when a post has been deleted and should be replaced with a "message deleted" // placeholder. This typically happens when a post is deleted by another user. export function postDeleted(post: Post) { return { type: PostTypes.POST_DELETED, data: post, }; } // postRemoved should be dispatched when a post should be immediately removed. This typically happens when a post is // deleted by the current user. export function postRemoved(post: Post) { return { type: PostTypes.POST_REMOVED, data: post, }; } export function getPost(postId: string) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { let post; try { post = await Client4.getPost(postId); getProfilesAndStatusesForPosts([post], dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(batchActions([ {type: PostTypes.GET_POSTS_FAILURE, error}, logError(error), ])); return {error}; } dispatch(batchActions([ receivedPost(post), { type: PostTypes.GET_POSTS_SUCCESS, }, ])); return {data: post}; }; } export function createPost(post: Post, files: any[] = []) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { const state = getState(); const currentUserId = state.entities.users.currentUserId; const timestamp = Date.now(); const pendingPostId = post.pending_post_id || `${currentUserId}:${timestamp}`; if (Selectors.isPostIdSending(state, pendingPostId)) { return {data: true}; } let newPost = { ...post, pending_post_id: pendingPostId, create_at: timestamp, update_at: timestamp, reply_count: 0, }; if (post.root_id) { newPost.reply_count = Selectors.getPostRepliesCount(state, post.root_id) + 1; } // We are retrying a pending post that had files if (newPost.file_ids && !files.length) { files = newPost.file_ids.map((id) => state.entities.files.files[id]); // eslint-disable-line } if (files.length) { const fileIds = files.map((file) => file.id); newPost = { ...newPost, file_ids: fileIds, }; dispatch({ type: FileTypes.RECEIVED_FILES_FOR_POST, postId: pendingPostId, data: files, }); } dispatch({ type: PostTypes.RECEIVED_NEW_POST, data: { ...newPost, id: pendingPostId, }, meta: { offline: { effect: () => Client4.createPost({...newPost, create_at: 0}), commit: (result: any, payload: any) => { const actions: Action[] = [ receivedPost(payload), { type: PostTypes.CREATE_POST_SUCCESS, }, { type: ChannelTypes.INCREMENT_TOTAL_MSG_COUNT, data: { channelId: newPost.channel_id, amount: 1, }, }, { type: ChannelTypes.DECREMENT_UNREAD_MSG_COUNT, data: { channelId: newPost.channel_id, amount: 1, }, }, ]; if (files) { actions.push({ type: FileTypes.RECEIVED_FILES_FOR_POST, postId: payload.id, data: files, }); } dispatch(batchActions(actions)); }, maxRetry: 0, offlineRollback: true, rollback: (result: any, error: ServerError) => { const data = { ...newPost, id: pendingPostId, failed: true, update_at: Date.now(), }; dispatch({type: PostTypes.CREATE_POST_FAILURE, error}); // If the failure was because: the root post was deleted or // TownSquareIsReadOnly=true then remove the post if (error.server_error_id === 'api.post.create_post.root_id.app_error' || error.server_error_id === 'api.post.create_post.town_square_read_only' || error.server_error_id === 'plugin.message_will_be_posted.dismiss_post' ) { dispatch(removePost(data) as any); } else { dispatch(receivedPost(data)); } }, }, }, }); return {data: true}; }; } export function createPostImmediately(post: Post, files: any[] = []) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { const state = getState(); const currentUserId = state.entities.users.currentUserId; const timestamp = Date.now(); const pendingPostId = `${currentUserId}:${timestamp}`; let newPost: Post = { ...post, pending_post_id: pendingPostId, create_at: timestamp, update_at: timestamp, reply_count: 0, }; if (post.root_id) { newPost.reply_count = Selectors.getPostRepliesCount(state, post.root_id) + 1; } if (files.length) { const fileIds = files.map((file) => file.id); newPost = { ...newPost, file_ids: fileIds, }; dispatch({ type: FileTypes.RECEIVED_FILES_FOR_POST, postId: pendingPostId, data: files, }); } dispatch(receivedNewPost({ ...newPost, id: pendingPostId, })); try { const created = await Client4.createPost({...newPost, create_at: 0}); newPost.id = created.id; newPost.reply_count = created.reply_count; } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(batchActions([ {type: PostTypes.CREATE_POST_FAILURE, data: newPost, error}, removePost({ ...newPost, id: pendingPostId, }) as any, logError(error), ])); return {error}; } const actions: Action[] = [ receivedPost(newPost), { type: PostTypes.CREATE_POST_SUCCESS, }, { type: ChannelTypes.INCREMENT_TOTAL_MSG_COUNT, data: { channelId: newPost.channel_id, amount: 1, }, }, { type: ChannelTypes.DECREMENT_UNREAD_MSG_COUNT, data: { channelId: newPost.channel_id, amount: 1, }, }, ]; if (files) { actions.push({ type: FileTypes.RECEIVED_FILES_FOR_POST, postId: newPost.id, data: files, }); } dispatch(batchActions(actions)); return {data: newPost}; }; } export function resetCreatePostRequest() { return {type: PostTypes.CREATE_POST_RESET_REQUEST}; } export function deletePost(post: ExtendedPost) { return (dispatch: DispatchFunc, getState: GetStateFunc) => { const state = getState(); const delPost = {...post}; if (delPost.type === Posts.POST_TYPES.COMBINED_USER_ACTIVITY && delPost.system_post_ids) { delPost.system_post_ids.forEach((systemPostId) => { const systemPost = Selectors.getPost(state, systemPostId); if (systemPost) { dispatch(deletePost(systemPost)); } }); } else { dispatch({ type: PostTypes.POST_DELETED, data: delPost, meta: { offline: { effect: () => Client4.deletePost(post.id), commit: {type: 'do_nothing'}, // redux-offline always needs to dispatch something on commit rollback: receivedPost(delPost), }, }, }); } return {data: true}; }; } export function editPost(post: Post) { return bindClientFunc({ clientFunc: Client4.patchPost, onRequest: PostTypes.EDIT_POST_REQUEST, onSuccess: [PostTypes.RECEIVED_POST, PostTypes.EDIT_POST_SUCCESS], onFailure: PostTypes.EDIT_POST_FAILURE, params: [ post, ], }); } function getUnreadPostData(unreadChan: ChannelUnread, state: GlobalState) { const member = getMyChannelMemberSelector(state, unreadChan.channel_id); const delta = member ? member.msg_count - unreadChan.msg_count : unreadChan.msg_count; const data = { teamId: unreadChan.team_id, channelId: unreadChan.channel_id, msgCount: unreadChan.msg_count, mentionCount: unreadChan.mention_count, lastViewedAt: unreadChan.last_viewed_at, deltaMsgs: delta, }; return data; } export function setUnreadPost(userId: string, postId: string) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { let state = getState(); const post = Selectors.getPost(state, postId); let unreadChan; try { if (isCombinedUserActivityPost(postId)) { return {}; } unreadChan = await Client4.markPostAsUnread(userId, postId); dispatch({ type: ChannelTypes.ADD_MANUALLY_UNREAD, data: { channelId: post.channel_id, }, }); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(logError(error)); dispatch({ type: ChannelTypes.REMOVE_MANUALLY_UNREAD, data: { channelId: post.channel_id, }, }); return {error}; } state = getState(); const data = getUnreadPostData(unreadChan, state); dispatch({ type: ChannelTypes.POST_UNREAD_SUCCESS, data, }); return {data}; }; } export function pinPost(postId: string) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { dispatch({type: PostTypes.EDIT_POST_REQUEST}); let posts; try { posts = await Client4.pinPost(postId); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(batchActions([ {type: PostTypes.EDIT_POST_FAILURE, error}, logError(error), ])); return {error}; } const actions: Action[] = [ { type: PostTypes.EDIT_POST_SUCCESS, }, ]; const post = Selectors.getPost(getState(), postId); if (post) { actions.push( receivedPost({ ...post, is_pinned: true, update_at: Date.now(), }), { type: ChannelTypes.INCREMENT_PINNED_POST_COUNT, id: post.channel_id, }, ); } dispatch(batchActions(actions)); return {data: posts}; }; } export function unpinPost(postId: string) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { dispatch({type: PostTypes.EDIT_POST_REQUEST}); let posts; try { posts = await Client4.unpinPost(postId); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(batchActions([ {type: PostTypes.EDIT_POST_FAILURE, error}, logError(error), ])); return {error}; } const actions: Action[] = [ { type: PostTypes.EDIT_POST_SUCCESS, }, ]; const post = Selectors.getPost(getState(), postId); if (post) { actions.push( receivedPost({ ...post, is_pinned: false, update_at: Date.now(), }), { type: ChannelTypes.DECREMENT_PINNED_POST_COUNT, id: post.channel_id, }, ); } dispatch(batchActions(actions)); return {data: posts}; }; } export function addReaction(postId: string, emojiName: string) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { const currentUserId = getState().entities.users.currentUserId; let reaction; try { reaction = await Client4.addReaction(currentUserId, postId, emojiName); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(logError(error)); return {error}; } dispatch({ type: PostTypes.RECEIVED_REACTION, data: reaction, }); return {data: true}; }; } export function removeReaction(postId: string, emojiName: string) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { const currentUserId = getState().entities.users.currentUserId; try { await Client4.removeReaction(currentUserId, postId, emojiName); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(logError(error)); return {error}; } dispatch({ type: PostTypes.REACTION_DELETED, data: {user_id: currentUserId, post_id: postId, emoji_name: emojiName}, }); return {data: true}; }; } export function getCustomEmojiForReaction(name: string) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { const nonExistentEmoji = getState().entities.emojis.nonExistentEmoji; const customEmojisByName = selectCustomEmojisByName(getState()); if (systemEmojis.has(name)) { return {data: true}; } if (nonExistentEmoji.has(name)) { return {data: true}; } if (customEmojisByName.has(name)) { return {data: true}; } return dispatch(getCustomEmojiByName(name)); }; } export function getReactionsForPost(postId: string) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { let reactions; try { reactions = await Client4.getReactionsForPost(postId); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(logError(error)); return {error}; } if (reactions && reactions.length > 0) { const nonExistentEmoji = getState().entities.emojis.nonExistentEmoji; const customEmojisByName = selectCustomEmojisByName(getState()); const emojisToLoad = new Set<string>(); reactions.forEach((r: Reaction) => { const name = r.emoji_name; if (systemEmojis.has(name)) { // It's a system emoji, go the next match return; } if (nonExistentEmoji.has(name)) { // We've previously confirmed this is not a custom emoji return; } if (customEmojisByName.has(name)) { // We have the emoji, go to the next match return; } emojisToLoad.add(name); }); dispatch(getCustomEmojisByName(Array.from(emojisToLoad))); } dispatch(batchActions([ { type: PostTypes.RECEIVED_REACTIONS, data: reactions, postId, }, ])); return reactions; }; } export function flagPost(postId: string) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { const {currentUserId} = getState().entities.users; const preference = { user_id: currentUserId, category: Preferences.CATEGORY_FLAGGED_POST, name: postId, value: 'true', }; Client4.trackEvent('action', 'action_posts_flag'); return savePreferences(currentUserId, [preference])(dispatch); }; } export function getPostThread(rootId: string, fetchThreads = true, collapsedThreads = false, collapsedThreadsExtended = false) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { dispatch({type: PostTypes.GET_POST_THREAD_REQUEST}); let posts; try { posts = await Client4.getPostThread(rootId, fetchThreads, collapsedThreads, collapsedThreadsExtended); getProfilesAndStatusesForPosts(posts.posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(batchActions([ {type: PostTypes.GET_POST_THREAD_FAILURE, error}, logError(error), ])); return {error}; } dispatch(batchActions([ receivedPosts(posts), receivedPostsInThread(posts, rootId), { type: PostTypes.GET_POST_THREAD_SUCCESS, }, ])); return {data: posts}; }; } export function getPosts(channelId: string, page = 0, perPage = Posts.POST_CHUNK_SIZE, fetchThreads = true, collapsedThreads = false, collapsedThreadsExtended = false) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { let posts; try { posts = await Client4.getPosts(channelId, page, perPage, fetchThreads, collapsedThreads, collapsedThreadsExtended); getProfilesAndStatusesForPosts(posts.posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(logError(error)); return {error}; } dispatch(batchActions([ receivedPosts(posts), receivedPostsInChannel(posts, channelId, page === 0, posts.prev_post_id === ''), ])); return {data: posts}; }; } export function getPostsUnread(channelId: string, fetchThreads = true, collapsedThreads = false, collapsedThreadsExtended = false) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { const userId = getCurrentUserId(getState()); let posts; try { posts = await Client4.getPostsUnread(channelId, userId, DEFAULT_LIMIT_BEFORE, DEFAULT_LIMIT_AFTER, fetchThreads, collapsedThreads, collapsedThreadsExtended); getProfilesAndStatusesForPosts(posts.posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(logError(error)); return {error}; } dispatch(batchActions([ receivedPosts(posts), receivedPostsInChannel(posts, channelId, posts.next_post_id === '', posts.prev_post_id === ''), ])); dispatch({ type: PostTypes.RECEIVED_POSTS, data: posts, channelId, }); return {data: posts}; }; } export function getPostsSince(channelId: string, since: number, fetchThreads = true, collapsedThreads = false, collapsedThreadsExtended = false) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { let posts; try { posts = await Client4.getPostsSince(channelId, since, fetchThreads, collapsedThreads, collapsedThreadsExtended); getProfilesAndStatusesForPosts(posts.posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(logError(error)); return {error}; } dispatch(batchActions([ receivedPosts(posts), receivedPostsSince(posts, channelId), { type: PostTypes.GET_POSTS_SINCE_SUCCESS, }, ])); return {data: posts}; }; } export function getPostsBefore(channelId: string, postId: string, page = 0, perPage = Posts.POST_CHUNK_SIZE, fetchThreads = true, collapsedThreads = false, collapsedThreadsExtended = false) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { let posts; try { posts = await Client4.getPostsBefore(channelId, postId, page, perPage, fetchThreads, collapsedThreads, collapsedThreadsExtended); getProfilesAndStatusesForPosts(posts.posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(logError(error)); return {error}; } dispatch(batchActions([ receivedPosts(posts), receivedPostsBefore(posts, channelId, postId, posts.prev_post_id === ''), ])); return {data: posts}; }; } export function getPostsAfter(channelId: string, postId: string, page = 0, perPage = Posts.POST_CHUNK_SIZE, fetchThreads = true, collapsedThreads = false, collapsedThreadsExtended = false) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { let posts; try { posts = await Client4.getPostsAfter(channelId, postId, page, perPage, fetchThreads, collapsedThreads, collapsedThreadsExtended); getProfilesAndStatusesForPosts(posts.posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(logError(error)); return {error}; } dispatch(batchActions([ receivedPosts(posts), receivedPostsAfter(posts, channelId, postId, posts.next_post_id === ''), ])); return {data: posts}; }; } export function getPostsAround(channelId: string, postId: string, perPage = Posts.POST_CHUNK_SIZE / 2, fetchThreads = true, collapsedThreads = false, collapsedThreadsExtended = false) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { let after; let thread; let before; try { [after, thread, before] = await Promise.all([ Client4.getPostsAfter(channelId, postId, 0, perPage, fetchThreads, collapsedThreads, collapsedThreadsExtended), Client4.getPostThread(postId, fetchThreads, collapsedThreads, collapsedThreadsExtended), Client4.getPostsBefore(channelId, postId, 0, perPage, fetchThreads, collapsedThreads, collapsedThreadsExtended), ]); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(logError(error)); return {error}; } // Dispatch a combined post list so that the order is correct for postsInChannel const posts: PostList = { posts: { ...after.posts, ...thread.posts, ...before.posts, }, order: [ // Remember that the order is newest posts first ...after.order, postId, ...before.order, ], next_post_id: after.next_post_id, prev_post_id: before.prev_post_id, }; getProfilesAndStatusesForPosts(posts.posts, dispatch, getState); dispatch(batchActions([ receivedPosts(posts), receivedPostsInChannel(posts, channelId, after.next_post_id === '', before.prev_post_id === ''), ])); return {data: posts}; }; } // getThreadsForPosts is intended for an array of posts that have been batched // (see the actions/websocket_actions/handleNewPostEvents function in the webapp) export function getThreadsForPosts(posts: Post[], fetchThreads = true) { return (dispatch: DispatchFunc, getState: GetStateFunc) => { if (!Array.isArray(posts) || !posts.length) { return {data: true}; } const state = getState(); const promises: Array<Promise<ActionResult>> = []; posts.forEach((post) => { if (!post.root_id) { return; } const rootPost = Selectors.getPost(state, post.root_id); if (!rootPost) { promises.push(dispatch(getPostThread(post.root_id, fetchThreads))); } }); return Promise.all(promises); }; } // Note that getProfilesAndStatusesForPosts can take either an array of posts or a map of ids to posts export function getProfilesAndStatusesForPosts(postsArrayOrMap: Post[]|Map<string, Post>, dispatch: DispatchFunc, getState: GetStateFunc) { if (!postsArrayOrMap) { // Some API methods return {error} for no results return Promise.resolve(); } const posts = Object.values(postsArrayOrMap); if (posts.length === 0) { return Promise.resolve(); } const state = getState(); const {currentUserId, profiles, statuses} = state.entities.users; // Statuses and profiles of the users who made the posts const userIdsToLoad = new Set<string>(); const statusesToLoad = new Set<string>(); Object.values(posts).forEach((post) => { const userId = post.user_id; if (!statuses[userId]) { statusesToLoad.add(userId); } if (userId === currentUserId) { return; } if (!profiles[userId]) { userIdsToLoad.add(userId); } }); const promises: any[] = []; if (userIdsToLoad.size > 0) { promises.push(getProfilesByIds(Array.from(userIdsToLoad))(dispatch, getState)); } if (statusesToLoad.size > 0) { promises.push(getStatusesByIds(Array.from(statusesToLoad))(dispatch, getState)); } // Profiles of users mentioned in the posts const usernamesToLoad = getNeededAtMentionedUsernames(state, posts); if (usernamesToLoad.size > 0) { promises.push(getProfilesByUsernames(Array.from(usernamesToLoad))(dispatch, getState)); } // Emojis used in the posts const emojisToLoad = getNeededCustomEmojis(state, posts); if (emojisToLoad && emojisToLoad.size > 0) { promises.push(getCustomEmojisByName(Array.from(emojisToLoad))(dispatch, getState)); } return Promise.all(promises); } export function getNeededAtMentionedUsernames(state: GlobalState, posts: Post[]): Set<string> { let usersByUsername: Dictionary<UserProfile>; // Populate this lazily since it's relatively expensive const usernamesToLoad = new Set<string>(); posts.forEach((post) => { if (!post.message.includes('@')) { return; } if (!usersByUsername) { usersByUsername = getUsersByUsername(state); } const pattern = /\B@(([a-z0-9_.-]*[a-z0-9_])[.-]*)/gi; let match; while ((match = pattern.exec(post.message)) !== null) { // match[1] is the matched mention including trailing punctuation // match[2] is the matched mention without trailing punctuation if (General.SPECIAL_MENTIONS.indexOf(match[2]) !== -1) { continue; } if (usersByUsername[match[1]] || usersByUsername[match[2]]) { // We have the user, go to the next match continue; } // If there's no trailing punctuation, this will only add 1 item to the set usernamesToLoad.add(match[1]); usernamesToLoad.add(match[2]); } }); return usernamesToLoad; } function buildPostAttachmentText(attachments: any[]) { let attachmentText = ''; attachments.forEach((a) => { if (a.fields && a.fields.length) { a.fields.forEach((f: any) => { attachmentText += ' ' + (f.value || ''); }); } if (a.pretext) { attachmentText += ' ' + a.pretext; } if (a.text) { attachmentText += ' ' + a.text; } }); return attachmentText; } export function getNeededCustomEmojis(state: GlobalState, posts: Post[]): Set<string> { if (getConfig(state).EnableCustomEmoji !== 'true') { return new Set<string>(); } // If post metadata is supported, custom emojis will have been provided as part of that if (posts[0].metadata) { return new Set<string>(); } let customEmojisToLoad = new Set<string>(); let customEmojisByName: Map<string, CustomEmoji>; // Populate this lazily since it's relatively expensive const nonExistentEmoji = state.entities.emojis.nonExistentEmoji; posts.forEach((post) => { if (post.message.includes(':')) { if (!customEmojisByName) { customEmojisByName = selectCustomEmojisByName(state); } const emojisFromPost = parseNeededCustomEmojisFromText(post.message, systemEmojis, customEmojisByName, nonExistentEmoji); if (emojisFromPost.size > 0) { customEmojisToLoad = new Set([...customEmojisToLoad, ...emojisFromPost]); } } const props = post.props; if (props && props.attachments && props.attachments.length) { if (!customEmojisByName) { customEmojisByName = selectCustomEmojisByName(state); } const attachmentText = buildPostAttachmentText(props.attachments); if (attachmentText) { const emojisFromAttachment = parseNeededCustomEmojisFromText(attachmentText, systemEmojis, customEmojisByName, nonExistentEmoji); if (emojisFromAttachment.size > 0) { customEmojisToLoad = new Set([...customEmojisToLoad, ...emojisFromAttachment]); } } } }); return customEmojisToLoad; } export type ExtendedPost = Post & { system_post_ids?: string[] }; export function removePost(post: ExtendedPost) { return (dispatch: DispatchFunc, getState: GetStateFunc) => { if (post.type === Posts.POST_TYPES.COMBINED_USER_ACTIVITY && post.system_post_ids) { const state = getState(); for (const systemPostId of post.system_post_ids) { const systemPost = Selectors.getPost(state, systemPostId); if (systemPost) { dispatch(removePost(systemPost as any) as any); } } } else { dispatch(postRemoved(post)); if (post.is_pinned) { dispatch( { type: ChannelTypes.DECREMENT_PINNED_POST_COUNT, id: post.channel_id, }, ); } } }; } export function selectPost(postId: string) { return async (dispatch: DispatchFunc) => { dispatch({ type: PostTypes.RECEIVED_POST_SELECTED, data: postId, }); return {data: true}; }; } export function selectFocusedPostId(postId: string) { return { type: PostTypes.RECEIVED_FOCUSED_POST, data: postId, }; } export function unflagPost(postId: string) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { const {currentUserId} = getState().entities.users; const preference = { user_id: currentUserId, category: Preferences.CATEGORY_FLAGGED_POST, name: postId, }; Client4.trackEvent('action', 'action_posts_unflag'); return deletePreferences(currentUserId, [preference])(dispatch, getState); }; } export function getOpenGraphMetadata(url: string) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { let data; try { data = await Client4.getOpenGraphMetadata(url); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(logError(error)); return {error}; } if (data && (data.url || data.type || data.title || data.description)) { dispatch({ type: PostTypes.RECEIVED_OPEN_GRAPH_METADATA, data, url, }); } return {data}; }; } export function doPostAction(postId: string, actionId: string, selectedOption = '') { return doPostActionWithCookie(postId, actionId, '', selectedOption); } export function doPostActionWithCookie(postId: string, actionId: string, actionCookie: string, selectedOption = '') { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { let data; try { data = await Client4.doPostActionWithCookie(postId, actionId, actionCookie, selectedOption); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(logError(error)); return {error}; } if (data && data.trigger_id) { dispatch({ type: IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID, data: data.trigger_id, }); } return {data}; }; } export function addMessageIntoHistory(message: string) { return async (dispatch: DispatchFunc) => { dispatch({ type: PostTypes.ADD_MESSAGE_INTO_HISTORY, data: message, }); return {data: true}; }; } export function resetHistoryIndex(index: number) { return async (dispatch: DispatchFunc) => { dispatch({ type: PostTypes.RESET_HISTORY_INDEX, data: index, }); return {data: true}; }; } export function moveHistoryIndexBack(index: number) { return async (dispatch: DispatchFunc) => { dispatch({ type: PostTypes.MOVE_HISTORY_INDEX_BACK, data: index, }); return {data: true}; }; } export function moveHistoryIndexForward(index: number) { return async (dispatch: DispatchFunc) => { dispatch({ type: PostTypes.MOVE_HISTORY_INDEX_FORWARD, data: index, }); return {data: true}; }; }