* Remove redux-offline and configure redux-persist * Fix typo * Fix configure store * Upgrade to redux-persist 6.0.0 * Add migration from redux-persist v4 to v6 * Replace AsyncStorage with MMKVStorage to boost storage speed * Mock RNFastStorage * Fix reactions test * Fix clearing the store on logout * Remove the need for LOGOUT_SUCCESS * No need to pass persistConfig to middlewares() * Remove unused imports * Export connection Accidentally removed this export. * Add batch action name Co-Authored-By: Elias Nahum <nahumhbl@gmail.com> * Add batch action name Co-Authored-By: Elias Nahum <nahumhbl@gmail.com> * Add batch action name Co-Authored-By: Elias Nahum <nahumhbl@gmail.com> * Add batch action name Co-Authored-By: Elias Nahum <nahumhbl@gmail.com> * Fix delete post * Fix leave channel Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
1224 lines
36 KiB
TypeScript
1224 lines
36 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
import {ChannelTypes, GeneralTypes, PostTypes} from '@mm-redux/action_types';
|
|
import {Posts} from '../../constants';
|
|
import {comparePosts} from '@mm-redux/utils/post_utils';
|
|
import {Post, PostsState, PostOrderBlock, MessageHistory} from '@mm-redux/types/posts';
|
|
import {RelationOneToOne, Dictionary, IDMappedObjects, RelationOneToMany} from '@mm-redux/types/utilities';
|
|
import {GenericAction} from '@mm-redux/types/actions';
|
|
import {Reaction} from '@mm-redux/types/reactions';
|
|
|
|
export function removeUnneededMetadata(post: Post) {
|
|
if (!post.metadata) {
|
|
return post;
|
|
}
|
|
|
|
const metadata = {...post.metadata};
|
|
let changed = false;
|
|
|
|
// These fields are stored separately
|
|
if (metadata.emojis) {
|
|
Reflect.deleteProperty(metadata, 'emojis');
|
|
changed = true;
|
|
}
|
|
|
|
if (metadata.files) {
|
|
Reflect.deleteProperty(metadata, 'files');
|
|
changed = true;
|
|
}
|
|
|
|
if (metadata.reactions) {
|
|
Reflect.deleteProperty(metadata, 'reactions');
|
|
changed = true;
|
|
}
|
|
|
|
if (metadata.embeds) {
|
|
let embedsChanged = false;
|
|
|
|
const newEmbeds = metadata.embeds.map((embed) => {
|
|
if (embed.type !== 'opengraph') {
|
|
return embed;
|
|
}
|
|
|
|
const newEmbed = {...embed};
|
|
Reflect.deleteProperty(newEmbed, 'data');
|
|
|
|
embedsChanged = true;
|
|
|
|
return newEmbed;
|
|
});
|
|
|
|
if (embedsChanged) {
|
|
metadata.embeds = newEmbeds;
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (!changed) {
|
|
// Nothing changed
|
|
return post;
|
|
}
|
|
|
|
return {
|
|
...post,
|
|
metadata,
|
|
};
|
|
}
|
|
|
|
export function handlePosts(state: RelationOneToOne<Post, Post> = {}, action: GenericAction) {
|
|
switch (action.type) {
|
|
case PostTypes.RECEIVED_POST:
|
|
case PostTypes.RECEIVED_NEW_POST: {
|
|
return handlePostReceived({...state}, action.data);
|
|
}
|
|
|
|
case PostTypes.RECEIVED_POSTS: {
|
|
const posts = Object.values(action.data.posts) as Post[];
|
|
|
|
if (posts.length === 0) {
|
|
return state;
|
|
}
|
|
|
|
const nextState = {...state};
|
|
|
|
for (const post of posts) {
|
|
handlePostReceived(nextState, post);
|
|
}
|
|
|
|
return nextState;
|
|
}
|
|
|
|
case PostTypes.POST_DELETED: {
|
|
const post: Post = action.data;
|
|
|
|
if (!state[post.id]) {
|
|
return state;
|
|
}
|
|
|
|
// Mark the post as deleted
|
|
const nextState = {
|
|
...state,
|
|
[post.id]: {
|
|
...state[post.id],
|
|
state: Posts.POST_DELETED,
|
|
file_ids: [],
|
|
has_reactions: false,
|
|
},
|
|
};
|
|
|
|
// Remove any of its comments
|
|
for (const otherPost of Object.values(state)) {
|
|
if (otherPost.root_id === post.id) {
|
|
Reflect.deleteProperty(nextState, otherPost.id);
|
|
}
|
|
}
|
|
|
|
return nextState;
|
|
}
|
|
|
|
case PostTypes.POST_REMOVED: {
|
|
const post = action.data;
|
|
|
|
if (!state[post.id]) {
|
|
return state;
|
|
}
|
|
|
|
// Remove the post itself
|
|
const nextState = {...state};
|
|
Reflect.deleteProperty(nextState, post.id);
|
|
|
|
// Remove any of its comments
|
|
for (const otherPost of Object.values(state)) {
|
|
if (otherPost.root_id === post.id) {
|
|
Reflect.deleteProperty(nextState, otherPost.id);
|
|
}
|
|
}
|
|
|
|
return nextState;
|
|
}
|
|
|
|
case ChannelTypes.RECEIVED_CHANNEL_DELETED:
|
|
case ChannelTypes.DELETE_CHANNEL_SUCCESS:
|
|
case ChannelTypes.LEAVE_CHANNEL: {
|
|
if (action.data && action.data.viewArchivedChannels) {
|
|
// Nothing to do since we still want to store posts in archived channels
|
|
return state;
|
|
}
|
|
|
|
const channelId = action.data.id;
|
|
|
|
let postDeleted = false;
|
|
|
|
// Remove any posts in the deleted channel
|
|
const nextState = {...state};
|
|
for (const post of Object.values(state)) {
|
|
if (post.channel_id === channelId) {
|
|
Reflect.deleteProperty(nextState, post.id);
|
|
postDeleted = true;
|
|
}
|
|
}
|
|
|
|
if (!postDeleted) {
|
|
// Nothing changed
|
|
return state;
|
|
}
|
|
|
|
return nextState;
|
|
}
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
function handlePostReceived(nextState: any, post: Post) {
|
|
if (nextState[post.id] && nextState[post.id].update_at >= post.update_at) {
|
|
// The stored post is newer than the one we've received
|
|
return nextState;
|
|
}
|
|
|
|
if (post.delete_at > 0) {
|
|
// We've received a deleted post, so mark the post as deleted if we already have it
|
|
if (nextState[post.id]) {
|
|
nextState[post.id] = {
|
|
...removeUnneededMetadata(post),
|
|
state: Posts.POST_DELETED,
|
|
file_ids: [],
|
|
has_reactions: false,
|
|
};
|
|
}
|
|
} else {
|
|
nextState[post.id] = removeUnneededMetadata(post);
|
|
}
|
|
|
|
// Delete any pending post that existed for this post
|
|
if (post.pending_post_id && post.id !== post.pending_post_id && nextState[post.pending_post_id]) {
|
|
Reflect.deleteProperty(nextState, post.pending_post_id);
|
|
}
|
|
|
|
return nextState;
|
|
}
|
|
|
|
export function handlePendingPosts(state: Array<string> = [], action: GenericAction) {
|
|
switch (action.type) {
|
|
case PostTypes.RECEIVED_NEW_POST: {
|
|
const post = action.data;
|
|
|
|
if (!post.pending_post_id) {
|
|
// This is not a pending post
|
|
return state;
|
|
}
|
|
|
|
const index = state.indexOf(post.pending_post_id);
|
|
|
|
if (index !== -1) {
|
|
// An entry already exists for this post
|
|
return state;
|
|
}
|
|
|
|
// Add the new pending post ID
|
|
const nextState = [...state];
|
|
nextState.push(post.pending_post_id);
|
|
|
|
return nextState;
|
|
}
|
|
case PostTypes.POST_REMOVED: {
|
|
const post = action.data;
|
|
|
|
const index = state.indexOf(post.id);
|
|
|
|
if (index === -1) {
|
|
// There's nothing to remove
|
|
return state;
|
|
}
|
|
|
|
// The post has been removed, so remove the entry for it
|
|
const nextState = [...state];
|
|
nextState.splice(index, 1);
|
|
|
|
return nextState;
|
|
}
|
|
case PostTypes.RECEIVED_POST: {
|
|
const post = action.data;
|
|
|
|
if (!post.pending_post_id) {
|
|
// This isn't a pending post
|
|
return state;
|
|
}
|
|
|
|
const index = state.indexOf(post.pending_post_id);
|
|
|
|
if (index === -1) {
|
|
// There's nothing to remove
|
|
return state;
|
|
}
|
|
|
|
// The post has actually been created, so remove the entry for it
|
|
const nextState = [...state];
|
|
nextState.splice(index, 1);
|
|
|
|
return nextState;
|
|
}
|
|
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
export function postsInChannel(state: Dictionary<Array<PostOrderBlock>> = {}, action: GenericAction, prevPosts: IDMappedObjects<Post>, nextPosts: Dictionary<Post>) {
|
|
switch (action.type) {
|
|
case PostTypes.RECEIVED_NEW_POST: {
|
|
const post = action.data as Post;
|
|
|
|
const postsForChannel = state[post.channel_id];
|
|
if (!postsForChannel) {
|
|
// Don't save newly created posts until the channel has been loaded
|
|
return state;
|
|
}
|
|
|
|
const recentBlockIndex = postsForChannel.findIndex((block: PostOrderBlock) => block.recent);
|
|
|
|
let nextRecentBlock: PostOrderBlock;
|
|
if (recentBlockIndex === -1) {
|
|
nextRecentBlock = {
|
|
order: [],
|
|
recent: true,
|
|
};
|
|
} else {
|
|
const recentBlock = postsForChannel[recentBlockIndex];
|
|
nextRecentBlock = {
|
|
...recentBlock,
|
|
order: [...recentBlock.order],
|
|
};
|
|
}
|
|
|
|
let changed = false;
|
|
|
|
// Add the new post to the channel
|
|
if (!nextRecentBlock.order.includes(post.id)) {
|
|
nextRecentBlock.order.unshift(post.id);
|
|
changed = true;
|
|
}
|
|
|
|
// If this is a newly created post, remove any pending post that exists for it
|
|
if (post.pending_post_id && post.id !== post.pending_post_id) {
|
|
const index = nextRecentBlock.order.indexOf(post.pending_post_id);
|
|
|
|
if (index !== -1) {
|
|
nextRecentBlock.order.splice(index, 1);
|
|
|
|
// Need to re-sort to make sure any other pending posts come first
|
|
nextRecentBlock.order.sort((a, b) => {
|
|
return comparePosts(nextPosts[a], nextPosts[b]);
|
|
});
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (!changed) {
|
|
return state;
|
|
}
|
|
|
|
const nextPostsForChannel = [...postsForChannel];
|
|
|
|
if (recentBlockIndex === -1) {
|
|
nextPostsForChannel.push(nextRecentBlock);
|
|
} else {
|
|
nextPostsForChannel[recentBlockIndex] = nextRecentBlock;
|
|
}
|
|
|
|
return {
|
|
...state,
|
|
[post.channel_id]: nextPostsForChannel,
|
|
};
|
|
}
|
|
|
|
case PostTypes.RECEIVED_POST: {
|
|
const post = action.data;
|
|
|
|
// Receiving a single post doesn't usually affect the order of posts in a channel, except for when we've
|
|
// received a newly created post that was previously stored as pending
|
|
|
|
if (!post.pending_post_id) {
|
|
return state;
|
|
}
|
|
|
|
const postsForChannel = state[post.channel_id] || [];
|
|
|
|
const recentBlockIndex = postsForChannel.findIndex((block: PostOrderBlock) => block.recent);
|
|
if (recentBlockIndex === -1) {
|
|
// Nothing to do since there's no recent block and only the recent block should contain pending posts
|
|
return state;
|
|
}
|
|
|
|
const recentBlock = postsForChannel[recentBlockIndex];
|
|
|
|
// Replace the pending post with the newly created one
|
|
const index = recentBlock.order.indexOf(post.pending_post_id);
|
|
if (index === -1) {
|
|
// No pending post found to remove
|
|
return state;
|
|
}
|
|
|
|
const nextRecentBlock = {
|
|
...recentBlock,
|
|
order: [...recentBlock.order],
|
|
};
|
|
|
|
nextRecentBlock.order[index] = post.id;
|
|
|
|
const nextPostsForChannel = [...postsForChannel];
|
|
nextPostsForChannel[recentBlockIndex] = nextRecentBlock;
|
|
|
|
return {
|
|
...state,
|
|
[post.channel_id]: nextPostsForChannel,
|
|
};
|
|
}
|
|
|
|
case PostTypes.RECEIVED_POSTS_IN_CHANNEL: {
|
|
const {recent, oldest} = action;
|
|
const order = action.data.order;
|
|
|
|
if (order.length === 0 && state[action.channelId]) {
|
|
// No new posts received when we already have posts
|
|
return state;
|
|
}
|
|
|
|
const postsForChannel = state[action.channelId] || [];
|
|
let nextPostsForChannel = [...postsForChannel];
|
|
|
|
if (recent) {
|
|
// The newly received block is now the most recent, so unmark the current most recent block
|
|
const recentBlockIndex = postsForChannel.findIndex((block: PostOrderBlock) => block.recent);
|
|
if (recentBlockIndex !== -1) {
|
|
const recentBlock = postsForChannel[recentBlockIndex];
|
|
|
|
if (recentBlock.order.length === order.length &&
|
|
recentBlock.order[0] === order[0] &&
|
|
recentBlock.order[recentBlock.order.length - 1] === order[order.length - 1]) {
|
|
// The newly received posts are identical to the most recent block, so there's nothing to do
|
|
return state;
|
|
}
|
|
|
|
// Unmark the most recent block since the new posts are more recent
|
|
const nextRecentBlock = {
|
|
...recentBlock,
|
|
recent: false,
|
|
};
|
|
|
|
nextPostsForChannel[recentBlockIndex] = nextRecentBlock;
|
|
}
|
|
}
|
|
|
|
// Add the new most recent block
|
|
nextPostsForChannel.push({
|
|
order,
|
|
recent,
|
|
oldest,
|
|
});
|
|
|
|
// Merge overlapping blocks
|
|
nextPostsForChannel = mergePostBlocks(nextPostsForChannel, nextPosts);
|
|
|
|
return {
|
|
...state,
|
|
[action.channelId]: nextPostsForChannel,
|
|
};
|
|
}
|
|
|
|
case PostTypes.RECEIVED_POSTS_AFTER: {
|
|
const order = action.data.order;
|
|
const afterPostId = action.afterPostId;
|
|
|
|
if (order.length === 0) {
|
|
// No posts received
|
|
return state;
|
|
}
|
|
|
|
const postsForChannel = state[action.channelId] || [];
|
|
|
|
// Add a new block including the previous post and then have mergePostBlocks sort out any overlap or duplicates
|
|
const newBlock = {
|
|
order: [...order, afterPostId],
|
|
recent: action.recent,
|
|
};
|
|
|
|
let nextPostsForChannel = [...postsForChannel, newBlock];
|
|
nextPostsForChannel = mergePostBlocks(nextPostsForChannel, nextPosts);
|
|
|
|
return {
|
|
...state,
|
|
[action.channelId]: nextPostsForChannel,
|
|
};
|
|
}
|
|
|
|
case PostTypes.RECEIVED_POSTS_BEFORE: {
|
|
const {order} = action.data;
|
|
const {beforePostId, oldest} = action;
|
|
|
|
if (order.length === 0) {
|
|
// No posts received
|
|
return state;
|
|
}
|
|
|
|
const postsForChannel = state[action.channelId] || [];
|
|
|
|
// Add a new block including the next post and then have mergePostBlocks sort out any overlap or duplicates
|
|
const newBlock = {
|
|
order: [beforePostId, ...order],
|
|
recent: false,
|
|
oldest,
|
|
};
|
|
|
|
let nextPostsForChannel = [...postsForChannel, newBlock];
|
|
nextPostsForChannel = mergePostBlocks(nextPostsForChannel, nextPosts);
|
|
|
|
return {
|
|
...state,
|
|
[action.channelId]: nextPostsForChannel,
|
|
};
|
|
}
|
|
|
|
case PostTypes.RECEIVED_POSTS_SINCE: {
|
|
const order = action.data.order;
|
|
|
|
if (order.length === 0 && state[action.channelId]) {
|
|
// No new posts received when we already have posts
|
|
return state;
|
|
}
|
|
|
|
const postsForChannel = state[action.channelId] || [];
|
|
|
|
const recentBlockIndex = postsForChannel.findIndex((block: PostOrderBlock) => block.recent);
|
|
if (recentBlockIndex === -1) {
|
|
// Nothing to do since this shouldn't be dispatched if we haven't loaded the most recent posts yet
|
|
return state;
|
|
}
|
|
|
|
const recentBlock = postsForChannel[recentBlockIndex];
|
|
|
|
const mostOldestCreateAt = nextPosts[recentBlock.order[recentBlock.order.length - 1]].create_at;
|
|
|
|
const nextRecentBlock: PostOrderBlock = {
|
|
...recentBlock,
|
|
order: [...recentBlock.order],
|
|
};
|
|
|
|
// Add any new posts to the most recent block while skipping ones that were only updated
|
|
for (let i = order.length - 1; i >= 0; i--) {
|
|
const postId = order[i];
|
|
|
|
if (!nextPosts[postId]) {
|
|
// the post was removed from the list
|
|
continue;
|
|
}
|
|
|
|
if (nextPosts[postId].create_at <= mostOldestCreateAt) {
|
|
// This is an old post
|
|
continue;
|
|
}
|
|
|
|
if (nextRecentBlock.order.indexOf(postId) !== -1) {
|
|
// This postId exists so no need to add it again
|
|
continue;
|
|
}
|
|
|
|
// This post is newer than what we have
|
|
nextRecentBlock.order.unshift(postId);
|
|
}
|
|
|
|
if (nextRecentBlock.order.length === recentBlock.order.length) {
|
|
// Nothing was added
|
|
return state;
|
|
}
|
|
|
|
nextRecentBlock.order.sort((a, b) => {
|
|
return comparePosts(nextPosts[a], nextPosts[b]);
|
|
});
|
|
|
|
const nextPostsForChannel = [...postsForChannel];
|
|
nextPostsForChannel[recentBlockIndex] = nextRecentBlock;
|
|
|
|
return {
|
|
...state,
|
|
[action.channelId]: nextPostsForChannel,
|
|
};
|
|
}
|
|
|
|
case PostTypes.POST_DELETED: {
|
|
const post = action.data;
|
|
|
|
// Deleting a post removes its comments from the order, but does not remove the post itself
|
|
|
|
const postsForChannel = state[post.channel_id] || [];
|
|
if (postsForChannel.length === 0) {
|
|
return state;
|
|
}
|
|
|
|
let changed = false;
|
|
|
|
let nextPostsForChannel = [...postsForChannel];
|
|
for (let i = 0; i < nextPostsForChannel.length; i++) {
|
|
const block = nextPostsForChannel[i];
|
|
|
|
// Remove any comments for this post
|
|
const nextOrder = block.order.filter((postId: string) => prevPosts[postId].root_id !== post.id);
|
|
|
|
if (nextOrder.length !== block.order.length) {
|
|
nextPostsForChannel[i] = {
|
|
...block,
|
|
order: nextOrder,
|
|
};
|
|
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (!changed) {
|
|
// Nothing was removed
|
|
return state;
|
|
}
|
|
|
|
nextPostsForChannel = removeNonRecentEmptyPostBlocks(nextPostsForChannel);
|
|
|
|
return {
|
|
...state,
|
|
[post.channel_id]: nextPostsForChannel,
|
|
};
|
|
}
|
|
|
|
case PostTypes.POST_REMOVED: {
|
|
const post = action.data;
|
|
|
|
// Removing a post removes it as well as its comments
|
|
|
|
const postsForChannel = state[post.channel_id] || [];
|
|
if (postsForChannel.length === 0) {
|
|
return state;
|
|
}
|
|
|
|
let changed = false;
|
|
|
|
// Remove the post and its comments from the channel
|
|
let nextPostsForChannel = [...postsForChannel];
|
|
for (let i = 0; i < nextPostsForChannel.length; i++) {
|
|
const block = nextPostsForChannel[i];
|
|
|
|
const nextOrder = block.order.filter((postId: string) => postId !== post.id && prevPosts[postId].root_id !== post.id);
|
|
|
|
if (nextOrder.length !== block.order.length) {
|
|
nextPostsForChannel[i] = {
|
|
...block,
|
|
order: nextOrder,
|
|
};
|
|
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (!changed) {
|
|
// Nothing was removed
|
|
return state;
|
|
}
|
|
|
|
nextPostsForChannel = removeNonRecentEmptyPostBlocks(nextPostsForChannel);
|
|
|
|
return {
|
|
...state,
|
|
[post.channel_id]: nextPostsForChannel,
|
|
};
|
|
}
|
|
|
|
case ChannelTypes.RECEIVED_CHANNEL_DELETED:
|
|
case ChannelTypes.DELETE_CHANNEL_SUCCESS:
|
|
case ChannelTypes.LEAVE_CHANNEL: {
|
|
if (action.data && action.data.viewArchivedChannels) {
|
|
// Nothing to do since we still want to store posts in archived channels
|
|
return state;
|
|
}
|
|
|
|
const channelId = action.data.id;
|
|
|
|
if (!state[channelId]) {
|
|
// Nothing to do since we have no posts for this channel
|
|
return state;
|
|
}
|
|
|
|
// Remove the entry for the deleted channel
|
|
const nextState = {...state};
|
|
Reflect.deleteProperty(nextState, channelId);
|
|
|
|
return nextState;
|
|
}
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
export function removeNonRecentEmptyPostBlocks(blocks: PostOrderBlock[]) {
|
|
return blocks.filter((block: PostOrderBlock) => block.order.length !== 0 || block.recent);
|
|
}
|
|
|
|
export function mergePostBlocks(blocks: PostOrderBlock[], posts: Dictionary<Post>) {
|
|
let nextBlocks = [...blocks];
|
|
|
|
// Remove any blocks that may have become empty by removing posts
|
|
nextBlocks = removeNonRecentEmptyPostBlocks(blocks);
|
|
|
|
// If a channel does not have any posts(Experimental feature where join and leave messages don't exist)
|
|
// return the previous state i.e an empty block
|
|
if (!nextBlocks.length) {
|
|
return blocks;
|
|
}
|
|
|
|
// Sort blocks so that the most recent one comes first
|
|
nextBlocks.sort((a, b) => {
|
|
const aStartsAt = posts[a.order[0]].create_at;
|
|
const bStartsAt = posts[b.order[0]].create_at;
|
|
|
|
return bStartsAt - aStartsAt;
|
|
});
|
|
|
|
// Merge adjacent blocks
|
|
let i = 0;
|
|
while (i < nextBlocks.length - 1) {
|
|
// Since we know the start of a is more recent than the start of b, they'll overlap if the last post in a is
|
|
// older than the first post in b
|
|
const a = nextBlocks[i];
|
|
const aEndsAt = posts[a.order[a.order.length - 1]].create_at;
|
|
|
|
const b = nextBlocks[i + 1];
|
|
const bStartsAt = posts[b.order[0]].create_at;
|
|
|
|
if (aEndsAt <= bStartsAt) {
|
|
// The blocks overlap, so combine them and remove the second block
|
|
nextBlocks[i] = {
|
|
order: mergePostOrder(a.order, b.order, posts),
|
|
};
|
|
|
|
nextBlocks[i].recent = a.recent || b.recent;
|
|
nextBlocks[i].oldest = a.oldest || b.oldest;
|
|
|
|
nextBlocks.splice(i + 1, 1);
|
|
|
|
// Do another iteration on this index since it may need to be merged into the next
|
|
} else {
|
|
// The blocks don't overlap, so move on to the next one
|
|
i += 1;
|
|
}
|
|
}
|
|
|
|
if (blocks.length === nextBlocks.length) {
|
|
// No changes were made
|
|
return blocks;
|
|
}
|
|
|
|
return nextBlocks;
|
|
}
|
|
|
|
export function mergePostOrder(left: string[], right: string[], posts: Dictionary<Post>) {
|
|
const result = [...left];
|
|
|
|
// Add without duplicates
|
|
const seen = new Set(left);
|
|
for (const id of right) {
|
|
if (seen.has(id)) {
|
|
continue;
|
|
}
|
|
|
|
result.push(id);
|
|
}
|
|
|
|
if (result.length === left.length) {
|
|
// No new items added
|
|
return left;
|
|
}
|
|
|
|
// Re-sort so that the most recent post comes first
|
|
result.sort((a, b) => posts[b].create_at - posts[a].create_at);
|
|
|
|
return result;
|
|
}
|
|
|
|
export function postsInThread(state: RelationOneToMany<Post, Post> = {}, action: GenericAction, prevPosts: Dictionary<Post>) {
|
|
switch (action.type) {
|
|
case PostTypes.RECEIVED_NEW_POST:
|
|
case PostTypes.RECEIVED_POST: {
|
|
const post = action.data;
|
|
|
|
if (!post.root_id) {
|
|
// Only store comments, not the root post
|
|
return state;
|
|
}
|
|
|
|
const postsForThread = state[post.root_id] || [];
|
|
const nextPostsForThread = [...postsForThread];
|
|
|
|
let changed = false;
|
|
|
|
if (!postsForThread.includes(post.id)) {
|
|
nextPostsForThread.push(post.id);
|
|
changed = true;
|
|
}
|
|
|
|
// If this is a new non-pending post, remove any pending post that exists for it
|
|
if (post.pending_post_id && post.id !== post.pending_post_id) {
|
|
const index = nextPostsForThread.indexOf(post.pending_post_id);
|
|
|
|
if (index !== -1) {
|
|
nextPostsForThread.splice(index, 1);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (!changed) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
...state,
|
|
[post.root_id]: nextPostsForThread,
|
|
};
|
|
}
|
|
|
|
case PostTypes.RECEIVED_POSTS_AFTER:
|
|
case PostTypes.RECEIVED_POSTS_BEFORE:
|
|
case PostTypes.RECEIVED_POSTS_IN_CHANNEL:
|
|
case PostTypes.RECEIVED_POSTS_SINCE: {
|
|
const newPosts: Post[] = Object.values(action.data.posts);
|
|
|
|
if (newPosts.length === 0) {
|
|
// Nothing to add
|
|
return state;
|
|
}
|
|
|
|
const nextState: Dictionary<string[]> = {};
|
|
|
|
for (const post of newPosts) {
|
|
if (!post.root_id) {
|
|
// Only store comments, not the root post
|
|
continue;
|
|
}
|
|
|
|
const postsForThread = state[post.root_id] || [];
|
|
const nextPostsForThread = nextState[post.root_id] || [...postsForThread];
|
|
|
|
// Add the post to the thread
|
|
if (!nextPostsForThread.includes(post.id)) {
|
|
nextPostsForThread.push(post.id);
|
|
}
|
|
|
|
nextState[post.root_id] = nextPostsForThread;
|
|
}
|
|
|
|
if (Object.keys(nextState).length === 0) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
...state,
|
|
...nextState,
|
|
};
|
|
}
|
|
|
|
case PostTypes.RECEIVED_POSTS_IN_THREAD: {
|
|
const newPosts: Post[] = Object.values(action.data.posts);
|
|
|
|
if (newPosts.length === 0) {
|
|
// Nothing to add
|
|
return state;
|
|
}
|
|
|
|
const postsForThread = state[action.rootId] || [];
|
|
const nextPostsForThread = [...postsForThread];
|
|
|
|
for (const post of newPosts) {
|
|
if (post.root_id !== action.rootId) {
|
|
// Only store comments
|
|
continue;
|
|
}
|
|
|
|
if (nextPostsForThread.includes(post.id)) {
|
|
// Don't store duplicates
|
|
continue;
|
|
}
|
|
|
|
nextPostsForThread.push(post.id);
|
|
}
|
|
|
|
return {
|
|
...state,
|
|
[action.rootId]: nextPostsForThread,
|
|
};
|
|
}
|
|
|
|
case PostTypes.POST_DELETED: {
|
|
const post = action.data;
|
|
|
|
const postsForThread = state[post.id];
|
|
if (!postsForThread) {
|
|
// Nothing to remove
|
|
return state;
|
|
}
|
|
|
|
const nextState = {...state};
|
|
Reflect.deleteProperty(nextState, post.id);
|
|
|
|
return nextState;
|
|
}
|
|
|
|
case PostTypes.POST_REMOVED: {
|
|
const post = action.data;
|
|
|
|
if (post.root_id) {
|
|
// This is a comment, so remove it from the thread
|
|
const postsForThread = state[post.root_id];
|
|
if (!postsForThread) {
|
|
return state;
|
|
}
|
|
|
|
const index = postsForThread.findIndex((postId) => postId === post.id);
|
|
if (index === -1) {
|
|
return state;
|
|
}
|
|
|
|
const nextPostsForThread = [...postsForThread];
|
|
nextPostsForThread.splice(index, 1);
|
|
|
|
return {
|
|
...state,
|
|
[post.root_id]: nextPostsForThread,
|
|
};
|
|
}
|
|
|
|
// This is not a comment, so remove any comments on it
|
|
const postsForThread = state[post.id];
|
|
if (!postsForThread) {
|
|
return state;
|
|
}
|
|
|
|
const nextState = {...state};
|
|
Reflect.deleteProperty(nextState, post.id);
|
|
|
|
return nextState;
|
|
}
|
|
|
|
case ChannelTypes.RECEIVED_CHANNEL_DELETED:
|
|
case ChannelTypes.DELETE_CHANNEL_SUCCESS:
|
|
case ChannelTypes.LEAVE_CHANNEL: {
|
|
if (action.data && action.data.viewArchivedChannels) {
|
|
// Nothing to do since we still want to store posts in archived channels
|
|
return state;
|
|
}
|
|
|
|
const channelId = action.data.id;
|
|
|
|
let postDeleted = false;
|
|
|
|
// Remove entries for any thread in the channel
|
|
const nextState = {...state};
|
|
for (const rootId of Object.keys(state)) {
|
|
if (prevPosts[rootId] && prevPosts[rootId].channel_id === channelId) {
|
|
Reflect.deleteProperty(nextState, rootId);
|
|
postDeleted = true;
|
|
}
|
|
}
|
|
|
|
if (!postDeleted) {
|
|
// Nothing was actually removed
|
|
return state;
|
|
}
|
|
|
|
return nextState;
|
|
}
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
function selectedPostId(state = '', action: GenericAction) {
|
|
switch (action.type) {
|
|
case PostTypes.RECEIVED_POST_SELECTED:
|
|
return action.data;
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
function currentFocusedPostId(state = '', action: GenericAction) {
|
|
switch (action.type) {
|
|
case PostTypes.RECEIVED_FOCUSED_POST:
|
|
return action.data;
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
export function reactions(state: RelationOneToOne<Post, Dictionary<Reaction>> = {}, action: GenericAction) {
|
|
switch (action.type) {
|
|
case PostTypes.RECEIVED_REACTIONS: {
|
|
const reactionsList = action.data;
|
|
const nextReactions: Dictionary<Reaction> = {};
|
|
reactionsList.forEach((reaction: Reaction) => {
|
|
nextReactions[reaction.user_id + '-' + reaction.emoji_name] = reaction;
|
|
});
|
|
|
|
return {
|
|
...state,
|
|
[action.postId!]: nextReactions,
|
|
};
|
|
}
|
|
case PostTypes.RECEIVED_REACTION: {
|
|
const reaction = action.data as Reaction;
|
|
const nextReactions = {...(state[reaction.post_id] || {})};
|
|
nextReactions[reaction.user_id + '-' + reaction.emoji_name] = reaction;
|
|
|
|
return {
|
|
...state,
|
|
[reaction.post_id]: nextReactions,
|
|
};
|
|
}
|
|
case PostTypes.REACTION_DELETED: {
|
|
const reaction = action.data;
|
|
const nextReactions = {...(state[reaction.post_id] || {})};
|
|
if (!nextReactions[reaction.user_id + '-' + reaction.emoji_name]) {
|
|
return state;
|
|
}
|
|
|
|
Reflect.deleteProperty(nextReactions, reaction.user_id + '-' + reaction.emoji_name);
|
|
|
|
return {
|
|
...state,
|
|
[reaction.post_id]: nextReactions,
|
|
};
|
|
}
|
|
|
|
case PostTypes.RECEIVED_NEW_POST:
|
|
case PostTypes.RECEIVED_POST: {
|
|
const post = action.data;
|
|
|
|
return storeReactionsForPost(state, post);
|
|
}
|
|
|
|
case PostTypes.RECEIVED_POSTS: {
|
|
const posts = Object.values(action.data.posts);
|
|
|
|
return posts.reduce(storeReactionsForPost, state);
|
|
}
|
|
|
|
case PostTypes.POST_DELETED:
|
|
case PostTypes.POST_REMOVED: {
|
|
const post = action.data;
|
|
|
|
if (post && state[post.id]) {
|
|
const nextState = {...state};
|
|
Reflect.deleteProperty(nextState, post.id);
|
|
|
|
return nextState;
|
|
}
|
|
|
|
return state;
|
|
}
|
|
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
function storeReactionsForPost(state: any, post: Post) {
|
|
if (!post.metadata || !post.metadata.reactions || post.delete_at > 0) {
|
|
return state;
|
|
}
|
|
|
|
const reactionsForPost: Dictionary<Reaction> = {};
|
|
if (post.metadata.reactions && post.metadata.reactions.length > 0) {
|
|
for (const reaction of post.metadata.reactions) {
|
|
reactionsForPost[reaction.user_id + '-' + reaction.emoji_name] = reaction;
|
|
}
|
|
}
|
|
|
|
return {
|
|
...state,
|
|
[post.id]: reactionsForPost,
|
|
};
|
|
}
|
|
|
|
export function openGraph(state: RelationOneToOne<Post, any> = {}, action: GenericAction) {
|
|
switch (action.type) {
|
|
case PostTypes.RECEIVED_OPEN_GRAPH_METADATA: {
|
|
const nextState = {...state};
|
|
nextState[action.url] = action.data;
|
|
|
|
return nextState;
|
|
}
|
|
|
|
case PostTypes.RECEIVED_NEW_POST:
|
|
case PostTypes.RECEIVED_POST: {
|
|
const post = action.data;
|
|
|
|
return storeOpenGraphForPost(state, post);
|
|
}
|
|
case PostTypes.RECEIVED_POSTS: {
|
|
const posts = Object.values(action.data.posts);
|
|
|
|
return posts.reduce(storeOpenGraphForPost, state);
|
|
}
|
|
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
function storeOpenGraphForPost(state: any, post: Post) {
|
|
if (!post.metadata || !post.metadata.embeds) {
|
|
return state;
|
|
}
|
|
|
|
return post.metadata.embeds.reduce((nextState, embed) => {
|
|
if (embed.type !== 'opengraph' || !embed.data) {
|
|
// Not an OpenGraph embed
|
|
return nextState;
|
|
}
|
|
|
|
const postIdState = nextState[post.id] ? {...nextState[post.id], [embed.url]: embed.data} : {[embed.url]: embed.data};
|
|
return {
|
|
...nextState,
|
|
[post.id]: postIdState,
|
|
};
|
|
}, state);
|
|
}
|
|
|
|
function messagesHistory(state: Partial<MessageHistory> = {}, action: GenericAction) {
|
|
switch (action.type) {
|
|
case PostTypes.ADD_MESSAGE_INTO_HISTORY: {
|
|
const nextIndex: Dictionary<number> = {};
|
|
let nextMessages = state.messages ? [...state.messages] : [];
|
|
nextMessages.push(action.data);
|
|
nextIndex[Posts.MESSAGE_TYPES.POST] = nextMessages.length;
|
|
nextIndex[Posts.MESSAGE_TYPES.COMMENT] = nextMessages.length;
|
|
|
|
if (nextMessages.length > Posts.MAX_PREV_MSGS) {
|
|
nextMessages = nextMessages.slice(1, Posts.MAX_PREV_MSGS + 1);
|
|
}
|
|
|
|
return {
|
|
messages: nextMessages,
|
|
index: nextIndex,
|
|
};
|
|
}
|
|
case PostTypes.RESET_HISTORY_INDEX: {
|
|
const index: Dictionary<number> = {};
|
|
index[Posts.MESSAGE_TYPES.POST] = -1;
|
|
index[Posts.MESSAGE_TYPES.COMMENT] = -1;
|
|
|
|
const messages = state.messages || [];
|
|
const nextIndex = state.index ? {...state.index} : index;
|
|
nextIndex[action.data] = messages.length;
|
|
return {
|
|
messages: state.messages,
|
|
index: nextIndex,
|
|
};
|
|
}
|
|
case PostTypes.MOVE_HISTORY_INDEX_BACK: {
|
|
const index: Dictionary<number> = {};
|
|
index[Posts.MESSAGE_TYPES.POST] = -1;
|
|
index[Posts.MESSAGE_TYPES.COMMENT] = -1;
|
|
|
|
const nextIndex = state.index ? {...state.index} : index;
|
|
if (nextIndex[action.data] > 0) {
|
|
nextIndex[action.data]--;
|
|
}
|
|
return {
|
|
messages: state.messages,
|
|
index: nextIndex,
|
|
};
|
|
}
|
|
case PostTypes.MOVE_HISTORY_INDEX_FORWARD: {
|
|
const index: Dictionary<number> = {};
|
|
index[Posts.MESSAGE_TYPES.POST] = -1;
|
|
index[Posts.MESSAGE_TYPES.COMMENT] = -1;
|
|
|
|
const messages = state.messages || [];
|
|
const nextIndex = state.index ? {...state.index} : index;
|
|
if (nextIndex[action.data] < messages.length) {
|
|
nextIndex[action.data]++;
|
|
}
|
|
return {
|
|
messages: state.messages,
|
|
index: nextIndex,
|
|
};
|
|
}
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
export function expandedURLs(state: Dictionary<string> = {}, action: GenericAction) {
|
|
switch (action.type) {
|
|
case GeneralTypes.REDIRECT_LOCATION_SUCCESS:
|
|
return {
|
|
...state,
|
|
[action.data.url]: action.data.location,
|
|
};
|
|
case GeneralTypes.REDIRECT_LOCATION_FAILURE:
|
|
return {
|
|
...state,
|
|
[action.data.url]: action.data.url,
|
|
};
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
export default function(state: Partial<PostsState> = {}, action: GenericAction) {
|
|
const nextPosts = handlePosts(state.posts, action);
|
|
const nextPostsInChannel = postsInChannel(state.postsInChannel, action, state.posts!, nextPosts);
|
|
|
|
const nextState = {
|
|
|
|
// Object mapping post ids to post objects
|
|
posts: nextPosts,
|
|
|
|
// Array that contains the pending post ids for those messages that are in transition to being created
|
|
pendingPostIds: handlePendingPosts(state.pendingPostIds, action),
|
|
|
|
// Object mapping channel ids to an array of posts ids in that channel with the most recent post first
|
|
postsInChannel: nextPostsInChannel,
|
|
|
|
// Object mapping post root ids to an array of posts ids of comments (but not the root post) in that thread
|
|
// with no guaranteed order
|
|
postsInThread: postsInThread(state.postsInThread, action, state.posts!),
|
|
|
|
// The current selected post
|
|
selectedPostId: selectedPostId(state.selectedPostId, action),
|
|
|
|
// The current selected focused post (permalink view)
|
|
currentFocusedPostId: currentFocusedPostId(state.currentFocusedPostId, action),
|
|
|
|
// Object mapping post ids to an object of emoji reactions using userId-emojiName as keys
|
|
reactions: reactions(state.reactions, action),
|
|
|
|
// Object mapping URLs to their relevant opengraph metadata for link previews
|
|
openGraph: openGraph(state.openGraph, action),
|
|
|
|
// History of posts and comments
|
|
messagesHistory: messagesHistory(state.messagesHistory, action),
|
|
|
|
expandedURLs: expandedURLs(state.expandedURLs, action),
|
|
};
|
|
|
|
if (state.posts === nextState.posts && state.postsInChannel === nextState.postsInChannel &&
|
|
state.postsInThread === nextState.postsInThread &&
|
|
state.pendingPostIds === nextState.pendingPostIds &&
|
|
state.selectedPostId === nextState.selectedPostId &&
|
|
state.currentFocusedPostId === nextState.currentFocusedPostId &&
|
|
state.reactions === nextState.reactions &&
|
|
state.openGraph === nextState.openGraph &&
|
|
state.messagesHistory === nextState.messagesHistory &&
|
|
state.expandedURLs === nextState.expandedURLs) {
|
|
// None of the children have changed so don't even let the parent object change
|
|
return state;
|
|
}
|
|
|
|
return nextState;
|
|
}
|