UNPKG

mattermost-redux

Version:

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

1,330 lines (1,053 loc) 38.9 kB
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {ChannelTypes, GeneralTypes, PostTypes, UserTypes} from 'action_types'; import {Posts} from '../../constants'; import {GenericAction} from 'types/actions'; import { OpenGraphMetadata, Post, PostsState, PostOrderBlock, MessageHistory, } from 'types/posts'; import {Reaction} from 'types/reactions'; import { $ID, RelationOneToOne, Dictionary, IDMappedObjects, RelationOneToMany, } from 'types/utilities'; import {comparePosts} from 'utils/post_utils'; 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 nextPostsReplies(state: {[x in $ID<Post>]: number} = {}, action: GenericAction) { switch (action.type) { case PostTypes.RECEIVED_POST: case PostTypes.RECEIVED_NEW_POST: { const post = action.data; if (!post.id || !post.root_id || !post.reply_count) { // Ignoring pending posts and root posts return state; } const newState = {...state}; newState[post.root_id] = post.reply_count; return newState; } 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) { if (post.root_id) { nextState[post.root_id] = post.reply_count; } else { nextState[post.id] = post.reply_count; } } return nextState; } case PostTypes.POST_DELETED: { const post: Post = action.data; if (!state[post.root_id] && !state[post.id]) { return state; } const nextState = {...state}; if (post.root_id && state[post.root_id]) { nextState[post.root_id] -= 1; } if (!post.root_id && state[post.id]) { Reflect.deleteProperty(nextState, post.id); } return nextState; } case UserTypes.LOGOUT_SUCCESS: return {}; default: return state; } } 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; } case UserTypes.LOGOUT_SUCCESS: return {}; 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: 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<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; } case UserTypes.LOGOUT_SUCCESS: return {}; 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; } case UserTypes.LOGOUT_SUCCESS: return {}; default: return state; } } function selectedPostId(state = '', action: GenericAction) { switch (action.type) { case PostTypes.RECEIVED_POST_SELECTED: return action.data; case UserTypes.LOGOUT_SUCCESS: return ''; default: return state; } } function currentFocusedPostId(state = '', action: GenericAction) { switch (action.type) { case PostTypes.RECEIVED_FOCUSED_POST: return action.data; case UserTypes.LOGOUT_SUCCESS: return ''; 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; } case UserTypes.LOGOUT_SUCCESS: return {}; 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, Dictionary<OpenGraphMetadata>> = {}, 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); } case UserTypes.LOGOUT_SUCCESS: return {}; 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, }; } case UserTypes.LOGOUT_SUCCESS: { const index: Dictionary<number> = {}; index[Posts.MESSAGE_TYPES.POST] = -1; index[Posts.MESSAGE_TYPES.COMMENT] = -1; return { messages: [], index, }; } 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 reducer(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, // Object mapping post ids to replies count postsReplies: nextPostsReplies(state.postsReplies, action), // 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; }