Files
mattermost-mobile/app/mm-redux/actions/posts.ts
Miguel Alatzar f79f9dc697 [MM-23761] [MM-25766] Add "More Messages" button (#4526)
* Add more messages button

* Update existing tests

* Various fixes:

* Use viewAreaCoveragePercentThreshold over itemVisiblePercentThreshold
* Remove currentUserId check when adding New Message line to postIds and
  instead update the channel last viewed time when an own post is received

* Update snapshots

* Add showMoreMessagesButton prop and default to false

* Android fixes

* Add tests

* Localize more messages text

* Use FormattedText

* i18 extract

* Style fixes

* Account for network indicator

* Fix for failing tests

* Various fixes:

* Set the unreadMessageCount when POST_UNREAD_SUCCESS is dispatched
  with a positive deltaMsgs
* Hide the more messages button when the unread count decreases or
  when the new message line is removed shortly after loading the
  channel

* No need for POST_UNREAD_SUCCESS if we manually call onViewableItemsChanged

* Reset unread count if current channel on channel mount

* Animate text opacity

* Compare indeces to determine when scrolling has ended

* Fix opacity animation trigger

* try with scrolling to the last rendered item

* Add onScrollEndIndex

* Improve animations

* Don't track moreCount in state

* Use moreText over prevNewMessageLineIndex to determine firstPage

* Update intl message format and call cancel in componentDidUpdate when needed

* Fix intl format

* Remove opacity animation and countText

* Fix pressed not being reset

* No need to separate intl func

* Return after resetting

* Fix accidental removal of setState call

* Reset pressed when newLineMessageIndex changes

* Use default windowSize and lower POST_CHUNK_SIZE and delays

* Queue a cancel timer that gets cleared only when the newMessageLineIndex changes

* Define uncancel func

* Increase cancelTimer delay

* Subtract read posts from unread count and account for retry indicator

* Add retry bar indicator tests

* Use props.unreadCount

* Fix handling of newMessageLineIndex change

* Fix handling of newMessageLineIndex change take 2

* Fix handling of newMessageLineIndex change take 3

* Use 'native' TouchableWithFeedback with dark overlay

* Fix handling of manually unread

* Update chunk and window sizes

* Fix hsl

* Update text only when newMessageLineIndex/endIndex is reached

* Don't delay cancel if when no more unreads

* Fixes for when opening the app

* No need to process viewableItems when unreadCount is 0

* Remove line

* Don't show if unreadCount is 0

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-07-09 13:30:30 -07:00

1308 lines
41 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Client4} from '@mm-redux/client';
import {General, Preferences, Posts} from '@mm-redux/constants';
import {WebsocketEvents} from '@constants';
import {PostTypes, ChannelTypes, FileTypes, IntegrationTypes} from '@mm-redux/action_types';
import {getCurrentChannelId, getMyChannelMember as getMyChannelMemberSelector, isManuallyUnread} from '@mm-redux/selectors/entities/channels';
import {getCustomEmojisByName as selectCustomEmojisByName} from '@mm-redux/selectors/entities/emojis';
import {getConfig} from '@mm-redux/selectors/entities/general';
import * as Selectors from '@mm-redux/selectors/entities/posts';
import {getCurrentUserId, getUsersByUsername} from '@mm-redux/selectors/entities/users';
import {getUserIdFromChannelName} from '@mm-redux/utils/channel_utils';
import {parseNeededCustomEmojisFromText} from '@mm-redux/utils/emoji_utils';
import {isFromWebhook, isSystemMessage, shouldIgnorePost} from '@mm-redux/utils/post_utils';
import {isCombinedUserActivityPost} from '@mm-redux/utils/post_list';
import {getSystemEmojis} from 'app/utils/emojis';
import {getMyChannelMember, markChannelAsUnread, markChannelAsRead, markChannelAsViewed} from './channels';
import {getCustomEmojiByName, getCustomEmojisByName} from './emojis';
import {logError} from './errors';
import {forceLogoutIfNecessary} from './helpers';
import {
deletePreferences,
makeDirectChannelVisibleIfNecessary,
makeGroupMessageVisibleIfNecessary,
savePreferences,
} from './preferences';
import {getProfilesByIds, getProfilesByUsernames, getStatusesByIds} from './users';
import {Action, ActionResult, batchActions, DispatchFunc, GetStateFunc, GenericAction} from '@mm-redux/types/actions';
import {ChannelUnread} from '@mm-redux/types/channels';
import {GlobalState} from '@mm-redux/types/store';
import {Post} from '@mm-redux/types/posts';
import {Reaction} from '@mm-redux/types/reactions';
import {UserProfile} from '@mm-redux/types/users';
import {Dictionary} from '@mm-redux/types/utilities';
import {CustomEmoji} from '@mm-redux/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: CombinedPostList) {
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: Array<Post>, 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: Array<Post>, 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: Array<Post>, 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: CombinedPostList, 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: Array<Post>, 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}`;
let actions: Array<Action> = [];
if (Selectors.isPostIdSending(state, pendingPostId)) {
return {data: true};
}
let newPost = {
...post,
pending_post_id: pendingPostId,
create_at: timestamp,
update_at: timestamp,
ownPost: true,
};
// 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,
};
actions.push({
type: FileTypes.RECEIVED_FILES_FOR_POST,
postId: pendingPostId,
data: files,
});
}
actions.push({
type: PostTypes.RECEIVED_NEW_POST,
data: {
...newPost,
id: pendingPostId,
},
});
dispatch(batchActions(actions, 'BATCH_CREATE_POST_INIT'));
try {
const created = await Client4.createPost({...newPost, create_at: 0});
actions = [
receivedPost({...created, ownPost: true}),
{
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: created.id,
data: files,
});
}
dispatch(batchActions(actions, 'BATCH_CREATE_POST'));
} catch (error) {
const data = {
...newPost,
id: pendingPostId,
failed: true,
update_at: Date.now(),
};
actions = [{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'
) {
actions.push(removePost(data) as any);
} else {
actions.push(receivedPost(data));
}
dispatch(batchActions(actions, 'BATCH_CREATE_POST_FAILED'));
}
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,
ownPost: true,
};
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});
const actions: Action[] = [
receivedPost({...created, ownPost: true}),
{
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));
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(batchActions([
{type: PostTypes.CREATE_POST_FAILURE, error},
removePost({...newPost, id: pendingPostId}) as any,
logError(error),
]));
return {error};
}
return {data: newPost};
};
}
export function resetCreatePostRequest() {
return {type: PostTypes.CREATE_POST_RESET_REQUEST};
}
export function deletePost(post: ExtendedPost) {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
const state = getState();
if (post.type === Posts.POST_TYPES.COMBINED_USER_ACTIVITY && post.system_post_ids) {
post.system_post_ids.forEach((systemPostId) => {
const systemPost = Selectors.getPost(state, systemPostId);
if (systemPost) {
dispatch(deletePost(systemPost));
}
});
} else {
dispatch({
type: PostTypes.POST_DELETED,
data: post,
});
try {
await Client4.deletePost(post.id);
} catch (error) {
dispatch(receivedPost(post));
}
}
return {data: true};
};
}
export function editPost(post: Post) {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
let data;
try {
data = await Client4.patchPost(post);
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
return {error};
}
dispatch(receivedPost(post));
return {data};
};
}
export 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());
const systemEmojis = getSystemEmojis();
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 systemEmojis = getSystemEmojis();
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) {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
dispatch({type: PostTypes.GET_POST_THREAD_REQUEST});
let posts;
try {
posts = await Client4.getPostThread(rootId);
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) {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
let posts;
try {
posts = await Client4.getPosts(channelId, page, perPage);
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) {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
const userId = getCurrentUserId(getState());
let posts;
try {
posts = await Client4.getPostsUnread(channelId, userId);
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) {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
let posts;
try {
posts = await Client4.getPostsSince(channelId, since);
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) {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
let posts;
try {
posts = await Client4.getPostsBefore(channelId, postId, page, perPage);
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) {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
let posts;
try {
posts = await Client4.getPostsAfter(channelId, postId, page, perPage);
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 type CombinedPostList = {
posts: Array<Post>;
order: Array<string>;
next_post_id: string;
prev_post_id: string;
}
export function getPostsAround(channelId: string, postId: string, perPage = Posts.POST_CHUNK_SIZE / 2) {
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),
Client4.getPostThread(postId),
Client4.getPostsBefore(channelId, postId, 0, perPage),
]);
} 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: CombinedPostList = {
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: Array<Post>) {
return (dispatch: DispatchFunc, getState: GetStateFunc) => {
if (!Array.isArray(posts) || !posts.length) {
return {data: true};
}
const state = getState();
const promises: 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)));
}
});
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: Array<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: Array<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: Array<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: Array<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;
const systemEmojis = getSystemEmojis();
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};
};
}
export function handleNewPost(msg: Omit<GenericAction, 'type'>) {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
const state = getState();
const currentUserId = getCurrentUserId(state);
const post = JSON.parse(msg.data.post);
const myChannelMember = getMyChannelMemberSelector(state, post.channel_id);
const websocketMessageProps = msg.data;
if (myChannelMember && Object.keys(myChannelMember).length === 0 && (myChannelMember as any).constructor === 'Object') {
await dispatch(getMyChannelMember(post.channel_id));
}
dispatch(completePostReceive(post, websocketMessageProps) as any);
if (msg.data.channel_type === General.DM_CHANNEL) {
const otherUserId = getUserIdFromChannelName(currentUserId, msg.data.channel_name);
dispatch(makeDirectChannelVisibleIfNecessary(otherUserId));
} else if (msg.data.channel_type === General.GM_CHANNEL) {
dispatch(makeGroupMessageVisibleIfNecessary(post.channel_id));
}
return {data: true};
};
}
function completePostReceive(post: Post, websocketMessageProps: any) {
return (dispatch: DispatchFunc, getState: GetStateFunc) => {
const state = getState();
const rootPost = Selectors.getPost(state, post.root_id);
if (post.root_id && !rootPost) {
dispatch(getPostThread(post.root_id));
}
dispatch(lastPostActions(post, websocketMessageProps) as any);
};
}
export function lastPostActions(post: Post, websocketMessageProps: any) {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
const state = getState();
const actions = [
receivedNewPost(post),
{
type: WebsocketEvents.STOP_TYPING,
data: {
id: post.channel_id + post.root_id,
userId: post.user_id,
now: Date.now(),
},
},
];
await dispatch(batchActions(actions));
if (shouldIgnorePost(post)) {
return;
}
let markAsRead = false;
let markAsReadOnServer = false;
if (!isManuallyUnread(getState(), post.channel_id)) {
if (
post.user_id === getCurrentUserId(state) &&
!isSystemMessage(post) &&
!isFromWebhook(post)
) {
markAsRead = true;
markAsReadOnServer = false;
} else if (post.channel_id === getCurrentChannelId(state)) {
markAsRead = true;
markAsReadOnServer = true;
}
}
if (markAsRead) {
await dispatch(markChannelAsRead(post.channel_id, undefined, markAsReadOnServer));
await dispatch(markChannelAsViewed(post.channel_id));
} else {
await dispatch(markChannelAsUnread(websocketMessageProps.team_id, post.channel_id, websocketMessageProps.mentions));
}
};
}