UNPKG

mattermost-redux

Version:

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

1,255 lines (1,254 loc) 58.3 kB
"use strict"; // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. Object.defineProperty(exports, "__esModule", { value: true }); exports.zeroStateLimitedViews = void 0; exports.removeUnneededMetadata = removeUnneededMetadata; exports.nextPostsReplies = nextPostsReplies; exports.handlePosts = handlePosts; exports.handlePendingPosts = handlePendingPosts; exports.postsInChannel = postsInChannel; exports.removeNonRecentEmptyPostBlocks = removeNonRecentEmptyPostBlocks; exports.mergePostBlocks = mergePostBlocks; exports.mergePostOrder = mergePostOrder; exports.postsInThread = postsInThread; exports.postEditHistory = postEditHistory; exports.reactions = reactions; exports.acknowledgements = acknowledgements; exports.openGraph = openGraph; exports.limitedViews = limitedViews; exports.default = reducer; const action_types_1 = require("mattermost-redux/action_types"); const constants_1 = require("mattermost-redux/constants"); const posts_1 = require("mattermost-redux/constants/posts"); const post_utils_1 = require("mattermost-redux/utils/post_utils"); function removeUnneededMetadata(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.reactions) { Reflect.deleteProperty(metadata, 'acknowledgements'); changed = true; } if (metadata.embeds) { let embedsChanged = false; const newEmbeds = metadata.embeds.map((embed) => { switch (embed.type) { case 'opengraph': { const newEmbed = { ...embed }; Reflect.deleteProperty(newEmbed, 'data'); embedsChanged = true; return newEmbed; } case 'permalink': { const permalinkEmbed = { ...embed }; if (permalinkEmbed.data) { Reflect.deleteProperty(permalinkEmbed.data, 'post'); } embedsChanged = true; return permalinkEmbed; } default: return embed; } }); if (embedsChanged) { metadata.embeds = newEmbeds; changed = true; } } if (!changed) { // Nothing changed return post; } return { ...post, metadata, }; } function nextPostsReplies(state = {}, action) { switch (action.type) { case action_types_1.PostTypes.RECEIVED_POST: case action_types_1.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 action_types_1.PostTypes.RECEIVED_POSTS: { const posts = Object.values(action.data.posts); 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 action_types_1.PostTypes.POST_DELETED: { const 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 action_types_1.UserTypes.LOGOUT_SUCCESS: return {}; default: return state; } } // Helper function to remove posts and permalink embeds for a set of channel IDs. function removePostsAndEmbedsForChannels(state, channelIds) { let postModified = false; const nextState = { ...state }; for (const post of Object.values(state)) { // Remove posts from the channels if (channelIds.has(post.channel_id)) { Reflect.deleteProperty(nextState, post.id); postModified = true; continue; } // Remove permalink embeds referencing those channels (matches server behavior) if (post.metadata?.embeds?.length) { const newEmbeds = []; let embedRemoved = false; for (const embed of post.metadata.embeds) { if (embed.type === 'permalink' && embed.data && channelIds.has(embed.data.channel_id)) { embedRemoved = true; } else { newEmbeds.push(embed); } } if (embedRemoved) { nextState[post.id] = { ...nextState[post.id], metadata: { ...nextState[post.id].metadata, embeds: newEmbeds, }, }; postModified = true; } } } if (!postModified) { return state; } return nextState; } function handlePosts(state = {}, action) { switch (action.type) { case action_types_1.PostTypes.RECEIVED_POST: case action_types_1.PostTypes.RECEIVED_NEW_POST: { return handlePostReceived({ ...state }, action.data); } case action_types_1.PostTypes.RECEIVED_POSTS: { const posts = Object.values(action.data.posts); if (posts.length === 0) { return state; } const nextState = { ...state }; for (const post of posts) { handlePostReceived(nextState, post); } return nextState; } case action_types_1.PostTypes.POST_DELETED: { const post = action.data; if (!state[post.id]) { return state; } if (state[post.id].type === posts_1.PostTypes.BURN_ON_READ) { const nextState = { ...state }; Reflect.deleteProperty(nextState, post.id); return nextState; } // Mark the post as deleted const nextState = { ...state, [post.id]: { ...state[post.id], state: constants_1.Posts.POST_DELETED, message: '', file_ids: [], has_reactions: false, }, }; for (const otherPost of Object.values(state)) { // Remove any of its comments if (otherPost.root_id === post.id) { Reflect.deleteProperty(nextState, otherPost.id); } // a deleted post may exist in some other post's // embeds when its link is mentioned in the post message. // We need to remove the deleted post from post embeds of all posts // to ensure the deleted post's contents cannot be retrieved from the store. if (otherPost.metadata && otherPost.metadata.embeds && otherPost.metadata.embeds.length > 0) { // This will become the post's new embeds array. // We'll add everything other than the deleted post's embed here. const newEmbeds = []; for (const embed of otherPost.metadata.embeds) { if (embed.type === 'permalink' && embed.data && embed.data.post_id === post.id) { // skip if the embed is the deleted post continue; } // include everything else newEmbeds.push(embed); } // if newEmbeds changed, update post's embeds if (newEmbeds.length !== otherPost.metadata.embeds.length) { // Since otherPost refers to the post from store, its frozen un immutable. // That's why cloning it and modifying required parts here. nextState[otherPost.id] = { ...nextState[otherPost.id], metadata: { ...nextState[otherPost.id].metadata, embeds: newEmbeds, }, }; } } } return nextState; } case action_types_1.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 action_types_1.PostTypes.POST_PINNED_CHANGED: { const { postId, isPinned, updateAt } = action; if (!state[postId]) { return state; } return { ...state, [postId]: { ...state[postId], is_pinned: isPinned, last_update_at: updateAt, }, }; } case action_types_1.PostTypes.REVEAL_BURN_ON_READ_SUCCESS: { const { post, expireAt } = action.data; if (!state[post.id]) { return state; } const currentPost = state[post.id]; const currentMetadata = currentPost.metadata || {}; const newMetadata = post.metadata || {}; return { ...state, [post.id]: { ...currentPost, ...post, metadata: { ...currentMetadata, ...newMetadata, expire_at: expireAt, }, }, }; } case action_types_1.PostTypes.POST_RECIPIENTS_UPDATED: { const { postId, recipients } = action.data; if (!state[postId]) { return state; } const currentPost = state[postId]; const currentMetadata = currentPost.metadata || {}; const currentRecipients = currentMetadata.recipients || []; // Merge new recipients with existing ones (don't replace). // Server sends incremental updates (only the revealing user), so we must merge. const mergedRecipients = [...new Set([...currentRecipients, ...recipients])]; return { ...state, [postId]: { ...currentPost, metadata: { ...currentMetadata, recipients: mergedRecipients, }, }, }; } case action_types_1.PostTypes.BURN_ON_READ_ALL_REVEALED: { const { postId, senderExpireAt } = action.data; if (!state[postId]) { return state; } const currentPost = state[postId]; const currentMetadata = currentPost.metadata || {}; // Set sender's expiration time to trigger timer display return { ...state, [postId]: { ...currentPost, metadata: { ...currentMetadata, expire_at: senderExpireAt, }, }, }; } case action_types_1.ChannelTypes.LEAVE_CHANNEL: { const channelId = action.data.id; return removePostsAndEmbedsForChannels(state, new Set([channelId])); } case action_types_1.TeamTypes.LEAVE_TEAM: { const channelIds = action.data.channelIds || []; if (channelIds.length === 0) { return state; } return removePostsAndEmbedsForChannels(state, new Set(channelIds)); } case action_types_1.ThreadTypes.FOLLOW_CHANGED_THREAD: { const { id, following } = action.data; const post = state[id]; return { ...state, [id]: { ...post, is_following: following, }, }; } case action_types_1.PostTypes.POST_TRANSLATION_UPDATED: { const data = action.data; if (!state[data.object_id]) { return state; } const existingTranslations = state[data.object_id].metadata?.translations || {}; const newTranslations = { ...existingTranslations, [data.language]: { object: data.translation ? JSON.parse(data.translation) : undefined, state: data.state, source_lang: data.src_lang, }, }; return { ...state, [data.object_id]: { ...state[data.object_id], metadata: { ...state[data.object_id].metadata, translations: newTranslations, }, }, }; } case action_types_1.UserTypes.LOGOUT_SUCCESS: return {}; default: return state; } } function handlePostReceived(nextState, post, nestedPermalinkLevel) { let currentState = nextState; // Check if post already exists in state or if nested permalink if (!(0, post_utils_1.shouldUpdatePost)(post, currentState[post.id]) || (nestedPermalinkLevel && nestedPermalinkLevel > 1)) { return currentState; } // If post is a permalink and not nested (it links directly to the original message), // and is missing embedded metadata, then update state with new post metadata if (!nestedPermalinkLevel && (0, post_utils_1.isPermalink)(post) && currentState[post.id] && !currentState[post.id].metadata && post.metadata) { currentState[post.id] = { ...currentState[post.id], ...post.metadata }; } // Posts that don't have CRT fields specified should maintain existing state. // This happens when posts are returned via GetPostsByIds (e.g. translation // supplement) which doesn't JOIN the Threads/ThreadMemberships tables. if (post.update_at > 0 && currentState[post.id]) { if (post.is_following == null) { post.is_following = currentState[post.id].is_following; } if (post.participants == null && currentState[post.id].participants) { post.participants = currentState[post.id].participants; } if (!post.last_reply_at && currentState[post.id].last_reply_at) { post.last_reply_at = currentState[post.id].last_reply_at; } } if (post.delete_at > 0) { // We've received a deleted post, so mark the post as deleted if we already have it if (currentState[post.id]) { currentState[post.id] = { ...removeUnneededMetadata(post), state: constants_1.Posts.POST_DELETED, file_ids: [], has_reactions: false, }; } } else if (post.metadata && post.metadata.embeds) { post.metadata.embeds.forEach((embed) => { if (embed.type === 'permalink') { if (embed.data && 'post_id' in embed.data && embed.data.post) { currentState = handlePostReceived(currentState, embed.data.post, nestedPermalinkLevel ? nestedPermalinkLevel + 1 : 1); if ((0, post_utils_1.isPermalink)(embed.data.post)) { currentState[post.id] = removeUnneededMetadata(post); } } } }); currentState[post.id] = post; } else { currentState[post.id] = removeUnneededMetadata(post); } // Delete any pending post that existed for this post if (post.pending_post_id && post.id !== post.pending_post_id && currentState[post.pending_post_id]) { Reflect.deleteProperty(currentState, post.pending_post_id); } const rootPost = currentState[post.root_id]; if (post.root_id && rootPost) { const participants = rootPost.participants || []; const nextRootPost = { ...rootPost }; if (!participants.find((user) => user.id === post.user_id)) { nextRootPost.participants = [...participants, { id: post.user_id }]; } if (post.reply_count) { nextRootPost.reply_count = post.reply_count; } currentState[post.root_id] = nextRootPost; } return currentState; } function handlePendingPosts(state = [], action) { switch (action.type) { case action_types_1.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 action_types_1.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 action_types_1.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; } } function postsInChannel(state = {}, action, prevPosts, nextPosts) { switch (action.type) { case action_types_1.PostTypes.RESET_POSTS_IN_CHANNEL: { const { channelId } = action; if (!channelId) { return {}; } const nextState = { ...state }; Reflect.deleteProperty(nextState, channelId); return nextState; } case action_types_1.PostTypes.RECEIVED_NEW_POST: { const post = action.data; if (action.features?.crtEnabled && post.root_id) { return state; } 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) => block.recent); let nextRecentBlock; 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 (0, post_utils_1.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 action_types_1.PostTypes.RECEIVED_POST: { const post = action.data; if (action.features?.crtEnabled && post.root_id) { return state; } // 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) => 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 action_types_1.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) => 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 action_types_1.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 action_types_1.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 action_types_1.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) => 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 = { ...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 (0, post_utils_1.comparePosts)(nextPosts[a], nextPosts[b]); }); const nextPostsForChannel = [...postsForChannel]; nextPostsForChannel[recentBlockIndex] = nextRecentBlock; return { ...state, [action.channelId]: nextPostsForChannel, }; } case action_types_1.PostTypes.POST_DELETED: { const post = action.data; const postsForChannel = state[post.channel_id] || []; if (postsForChannel.length === 0) { return state; } let changed = false; let nextPostsForChannel = [...postsForChannel]; const isBoRPost = prevPosts[post.id]?.type === posts_1.PostTypes.BURN_ON_READ; const shouldRemovePost = (postId) => { const isTheDeletedPost = postId === post.id; const isReplyToDeletedPost = prevPosts[postId]?.root_id === post.id; if (isBoRPost) { return isTheDeletedPost; } return isReplyToDeletedPost; }; for (let i = 0; i < nextPostsForChannel.length; i++) { const block = nextPostsForChannel[i]; const nextOrder = block.order.filter((postId) => !shouldRemovePost(postId)); 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 action_types_1.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; const isBoRPost = prevPosts[post.id]?.type === posts_1.PostTypes.BURN_ON_READ; // Remove the post and its comments from the channel let nextPostsForChannel = [...postsForChannel]; for (let i = 0; i < nextPostsForChannel.length; i++) { const block = nextPostsForChannel[i]; // For BoR posts: only remove the post itself (BoR doesn't support threads) // For regular posts: remove the post and its thread replies const nextOrder = isBoRPost ? block.order.filter((postId) => postId !== post.id) : block.order.filter((postId) => 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 action_types_1.ChannelTypes.LEAVE_CHANNEL: { 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 action_types_1.UserTypes.LOGOUT_SUCCESS: return {}; default: return state; } } function removeNonRecentEmptyPostBlocks(blocks) { return blocks.filter((block) => block.order.length !== 0 || block.recent); } function mergePostBlocks(blocks, posts) { 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; } function mergePostOrder(left, right, posts) { 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; } function postsInThread(state = {}, action, prevPosts) { switch (action.type) { case action_types_1.PostTypes.RECEIVED_NEW_POST: case action_types_1.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 action_types_1.PostTypes.RECEIVED_POSTS_AFTER: case action_types_1.PostTypes.RECEIVED_POSTS_BEFORE: case action_types_1.PostTypes.RECEIVED_POSTS_IN_CHANNEL: case action_types_1.PostTypes.RECEIVED_POSTS_SINCE: { const newPosts = Object.values(action.data.posts); if (newPosts.length === 0) { // Nothing to add return state; } const nextState = {}; 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 action_types_1.PostTypes.RECEIVED_POSTS_IN_THREAD: { const newPosts = 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 action_types_1.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 action_types_1.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 action_types_1.ChannelTypes.LEAVE_CHANNEL: { 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 action_types_1.UserTypes.LOGOUT_SUCCESS: return {}; default: return state; } } function postEditHistory(state = [], action) { switch (action.type) { case action_types_1.PostTypes.RECEIVED_POST_HISTORY: return action.data; case action_types_1.UserTypes.LOGOUT_SUCCESS: return []; default: return state; } } function currentFocusedPostId(state = '', action) { switch (action.type) { case action_types_1.PostTypes.RECEIVED_FOCUSED_POST: return action.data; case action_types_1.UserTypes.LOGOUT_SUCCESS: return ''; default: return state; } } function reactions(state = {}, action) { switch (action.type) { case action_types_1.PostTypes.RECEIVED_REACTION: { const reaction = action.data; const nextReactions = { ...(state[reaction.post_id] || {}) }; nextReactions[reaction.user_id + '-' + reaction.emoji_name] = reaction; return { ...state, [reaction.post_id]: nextReactions, }; } case action_types_1.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 action_types_1.PostTypes.RECEIVED_NEW_POST: case action_types_1.PostTypes.RECEIVED_POST: { const post = action.data; return storeReactionsForPost(state, post); } case action_types_1.PostTypes.RECEIVED_POSTS: { const posts = Object.values(action.data.posts); return posts.reduce(storeReactionsForPost, state); } case action_types_1.PostTypes.POST_DELETED: case action_types_1.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 action_types_1.UserTypes.LOGOUT_SUCCESS: return {}; default: return state; } } function acknowledgements(state = {}, action) { switch (action.type) { case action_types_1.PostTypes.CREATE_ACK_POST_SUCCESS: { const ack = action.data; const oldState = state[ack.post_id] || {}; return { ...state, [ack.post_id]: { ...oldState, [ack.user_id]: ack.acknowledged_at, }, }; } case action_types_1.PostTypes.DELETE_ACK_POST_SUCCESS: { const ack = action.data; if (!state[ack.post_id] || !state[ack.post_id][ack.user_id]) { return state; } // avoid a race condition const acknowledgedAt = state[ack.post_id][ack.user_id]; if (acknowledgedAt > ack.acknowledged_at) { return state; } const nextState = { ...(state[ack.post_id]) }; Reflect.deleteProperty(nextState, ack.user_id); return { ...state, [ack.post_id]: { ...nextState, }, }; } case action_types_1.PostTypes.RECEIVED_POST: { const post = action.data; return storeAcknowledgementsForPost(state, post); } case action_types_1.PostTypes.RECEIVED_POSTS: { const posts = Object.values(action.data.posts); return posts.reduce(storeAcknowledgementsForPost, state); } case action_types_1.PostTypes.POST_DELETED: case action_types_1.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 action_types_1.UserTypes.LOGOUT_SUCCESS: return {}; default: return state; } } function storeReactionsForPost(state, post) { if (!post.metadata || post.delete_at > 0) { return state; } const reactionsForPost = {}; 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, }; } function storeAcknowledgementsForPost(state, post) { if (!post.metadata || !post.metadata.acknowledgements || !post.metadata.acknowledgements.length || post.delete_at > 0) { return state; } const acknowledgementsForPost = {}; if (post?.metadata?.acknowledgements && post.metadata.acknowledgements.length > 0) { for (const ack of post.metadata.acknowledgements) { acknowledgementsForPost[ack.user_id] = ack.acknowledged_at; } } return { ...state, [post.id]: acknowledgementsForPost, }; } function openGraph(state = {}, action) { switch (action.type) { case action_types_1.PostTypes.RECEIVED_NEW_POST: case action_types_1.PostTypes.RECEIVED_POST: { const post = action.data; return storeOpenGraphForPost(state, post); } case action_types_1.PostTypes.RECEIVED_POSTS: { const posts = Object.values(action.data.posts); return posts.reduce(storeOpenGraphForPost, state); } case action_types_1.UserTypes.LOGOUT_SUCCESS: return {}; default: return state; } } function storeOpenGraphForPost(state, post) { if (!post.metadata || !post.metadata.embeds) { return state; } return post.metadata.embeds.reduce((nextState, embed) => { // If post contains a permalink, we need to store opengraph data for the embedded message if (embed.type === 'permalink' && embed.data && 'post' in embed.data && embed.data.post) { const previewPost = embed.data.post; if (previewPost.metadata && previewPost.metadata.embeds) { return previewPost.metadata.embeds.reduce((nextState, embed) => { if (embed.type !== 'opengraph' || !embed.data || nextState[previewPost.id]) { return nextState; } return { ...nextState, [previewPost.id]: { [embed.url]: embed.data }, }; }, nextState); } } 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,