// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. // /* eslint-disable max-lines */ import {markChannelAsUnread, updateLastPostAt} from '@actions/local/channel'; import {addPostAcknowledgement, removePost, removePostAcknowledgement, storePostsForChannel} from '@actions/local/post'; import {addRecentReaction} from '@actions/local/reactions'; import {createThreadFromNewPost} from '@actions/local/thread'; import {fetchChannelStats} from '@actions/remote/channel'; import {ActionType, General, Post, ServerErrors} from '@constants'; import DatabaseManager from '@database/manager'; import {filterPostsInOrderedArray} from '@helpers/api/post'; import {getNeededAtMentionedUsernames} from '@helpers/api/user'; import NetworkManager from '@managers/network_manager'; import {getMyChannel, prepareMissingChannelsForAllTeams, queryAllMyChannel} from '@queries/servers/channel'; import {queryAllCustomEmojis} from '@queries/servers/custom_emoji'; import {getFilesByIds, queryFilesForPost} from '@queries/servers/file'; import {getPostById, getRecentPostsInChannel} from '@queries/servers/post'; import {getCurrentUserId} from '@queries/servers/system'; import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers/thread'; import {queryAllUsers} from '@queries/servers/user'; import EphemeralStore from '@store/ephemeral_store'; import {setFetchingThreadState} from '@store/fetching_thread_store'; import {isBoRPost} from '@utils/bor'; import {getValidEmojis, matchEmoticons} from '@utils/emoji/helpers'; import {getFullErrorMessage, isServerError} from '@utils/errors'; import {hasArrayChanged} from '@utils/helpers'; import {logDebug, logError} from '@utils/log'; import {processPostsFetched} from '@utils/post'; import {getPostIdsForCombinedUserActivityPost} from '@utils/post_list'; import {processChannelPostsByTeam} from './post.auxiliary'; import {forceLogoutIfNecessary} from './session'; import type {Client} from '@client/rest'; import type Model from '@nozbe/watermelondb/Model'; import type PostModel from '@typings/database/models/servers/post'; type PostsRequest = { error?: unknown; order?: string[]; posts?: Post[]; previousPostId?: string; } export type PostsForChannel = PostsRequest & { actionType?: string; authors?: UserProfile[]; channelId?: string; error?: unknown; }; type PostsObjectsRequest = { error?: unknown; order?: string[]; posts?: IDMappedObjects; previousPostId?: string; } type AuthorsRequest = { authors?: UserProfile[]; error?: unknown; } export async function createPost(serverUrl: string, post: Partial, files: FileInfo[] = []): Promise<{data?: boolean; post?: Post; error?: unknown}> { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return {error: `${serverUrl} database not found`}; } const {database} = operator; let client: Client; try { client = NetworkManager.getClient(serverUrl); } catch (error) { return {error}; } const currentUserId = await getCurrentUserId(database); const timestamp = Date.now(); const pendingPostId = post.pending_post_id || `${currentUserId}:${timestamp}`; const existing = await getPostById(database, pendingPostId); if (existing && !existing.props?.failed) { return {data: false}; } let newPost = { ...post, id: '', pending_post_id: pendingPostId, create_at: timestamp, update_at: timestamp, delete_at: 0, } as Post; if (files.length) { const fileIds = files.map((file) => file.id); newPost = { ...newPost, file_ids: fileIds, }; } const databasePost = { ...newPost, id: pendingPostId, }; const initialPostModels: Model[] = []; if (files.length) { for (const f of files) { // Set the pending post Id f.post_id = pendingPostId; } const filesModels = await operator.handleFiles({files, prepareRecordsOnly: true}); initialPostModels.push(...filesModels); } const postModels = await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_NEW, order: [databasePost.id], posts: [databasePost], prepareRecordsOnly: true, }); initialPostModels.push(...postModels); const customEmojis = await queryAllCustomEmojis(database).fetch(); const emojisInMessage = matchEmoticons(newPost.message); const reactionModels = await addRecentReaction(serverUrl, getValidEmojis(emojisInMessage, customEmojis), true); if (!('error' in reactionModels) && reactionModels.length) { initialPostModels.push(...reactionModels); } await operator.batchRecords(initialPostModels, 'createPost - initial'); const isCRTEnabled = await getIsCRTEnabled(database); let created; try { created = await client.createPost({...newPost, create_at: 0}); } catch (error) { logDebug('Error sending a post', getFullErrorMessage(error)); const errorPost = { ...newPost, id: pendingPostId, props: { ...newPost.props, failed: true, }, update_at: Date.now(), }; // If the failure was because: the root post was deleted or // TownSquareIsReadOnly=true then remove the post if (isServerError(error) && ( error.server_error_id === ServerErrors.DELETED_ROOT_POST_ERROR || error.server_error_id === ServerErrors.TOWN_SQUARE_READ_ONLY_ERROR || error.server_error_id === ServerErrors.PLUGIN_DISMISSED_POST_ERROR )) { await removePost(serverUrl, databasePost); } else { const models = await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_NEW, order: [errorPost.id], posts: [errorPost], prepareRecordsOnly: true, }); if (isCRTEnabled) { const {models: threadModels} = await createThreadFromNewPost(serverUrl, errorPost, true); if (threadModels?.length) { models.push(...threadModels); } } await operator.batchRecords(models, 'createPost - failure'); } return {data: true}; } const models = await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_NEW, order: [created.id], posts: [created], prepareRecordsOnly: true, }); const isCrtReply = isCRTEnabled && created.root_id !== ''; if (!isCrtReply) { const {member} = await updateLastPostAt(serverUrl, created.channel_id, created.create_at, true); if (member) { models.push(member); } } if (isCRTEnabled) { const {models: threadModels} = await createThreadFromNewPost(serverUrl, created, true); if (threadModels?.length) { models.push(...threadModels); } } await operator.batchRecords(models, 'createPost - success'); newPost = created; return {data: true, post: created}; } export const retryFailedPost = async (serverUrl: string, post: PostModel) => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return {error: `${serverUrl} database not found`}; } const {database} = operator; let client: Client; try { client = NetworkManager.getClient(serverUrl); } catch (error) { return {error}; } const isCRTEnabled = await getIsCRTEnabled(database); try { const timestamp = Date.now(); const apiPost = await post.toApi(); const newPost = { ...apiPost, props: { ...apiPost.props, failed: false, }, id: '', create_at: timestamp, update_at: timestamp, delete_at: 0, } as Post; // Update the local post to reflect the pending state in the UI // timestamps will remain the same as the initial attempt for createAt // but updateAt will be use for the optimistic post UI post.prepareUpdate((p) => { p.props = newPost.props || null; p.updateAt = timestamp; }); await operator.batchRecords([post], 'retryFailedPost - first update'); const created = await client.createPost(newPost); const models = await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_NEW, order: [created.id], posts: [created], prepareRecordsOnly: true, }); const isCrtReply = isCRTEnabled && created.root_id !== ''; if (!isCrtReply) { const {member} = await updateLastPostAt(serverUrl, created.channel_id, created.create_at, true); if (member) { models.push(member); } } await operator.batchRecords(models, 'retryFailedPost - success update'); } catch (error) { logDebug('error on retryFailedPost', getFullErrorMessage(error)); if (isServerError(error) && ( error.server_error_id === ServerErrors.DELETED_ROOT_POST_ERROR || error.server_error_id === ServerErrors.TOWN_SQUARE_READ_ONLY_ERROR || error.server_error_id === ServerErrors.PLUGIN_DISMISSED_POST_ERROR )) { await removePost(serverUrl, post); } else { post.prepareUpdate((p) => { p.props = { ...p.props, failed: true, }; }); await operator.batchRecords([post], 'retryFailedPost - error update'); } return {error}; } return {}; }; export async function fetchPostsForChannel(serverUrl: string, channelId: string, fetchOnly = false, skipAuthors = false, groupLabel?: RequestGroupLabel): Promise { try { if (!fetchOnly) { EphemeralStore.addLoadingMessagesForChannel(serverUrl, channelId); } const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); let postAction: Promise|undefined; let actionType: string|undefined; const myChannel = await getMyChannel(database, channelId); const postsInChannel = await getRecentPostsInChannel(database, channelId); const since = myChannel?.lastFetchedAt || postsInChannel?.[0]?.createAt || 0; if (since) { postAction = fetchPostsSince(serverUrl, channelId, since, true, groupLabel); actionType = ActionType.POSTS.RECEIVED_SINCE; } else { postAction = fetchPosts(serverUrl, channelId, 0, General.POST_CHUNK_SIZE, true, groupLabel); actionType = ActionType.POSTS.RECEIVED_IN_CHANNEL; } const data = await postAction; if (data.error) { throw data.error; } let authors: UserProfile[] = []; if (data.posts?.length && data.order?.length) { if (!skipAuthors) { const {authors: fetchedAuthors} = await fetchPostAuthors(serverUrl, data.posts, true, groupLabel); authors = fetchedAuthors || []; } if (!fetchOnly) { await storePostsForChannel( serverUrl, channelId, data.posts, data.order, data.previousPostId ?? '', actionType, authors, ); } } return {posts: data.posts, order: data.order, authors, actionType, previousPostId: data.previousPostId, channelId}; } catch (error) { logDebug('error on fetchPostsForChannel', getFullErrorMessage(error)); return {error}; } finally { if (!fetchOnly) { EphemeralStore.stopLoadingMessagesForChannel(serverUrl, channelId); } } } export const fetchPostsForUnreadChannels = async ( serverUrl: string, teams: Team[], channels: Channel[], memberships: ChannelMembership[], excludeChannelId?: string, isCRTEnabled?: boolean, groupLabel?: RequestGroupLabel, ): Promise => { const teamIndexMap = new Map(); teams.forEach((team, index) => teamIndexMap.set(team.id, index)); const channelsMap = new Map(); for (const channel of channels) { if (channel.id !== excludeChannelId && !channel.delete_at) { channelsMap.set(channel.id, channel); } } const sortedUnreadchannelIdsByTeam = memberships.filter((member) => { const channel = channelsMap.get(member.channel_id); if (channel) { const unreads = isCRTEnabled ? (channel.total_msg_count_root ?? 0) - (member.msg_count_root ?? 0) : channel.total_msg_count - member.msg_count; return unreads > 0; // Keep only channels with unreads } return false; }). sort((a, b) => { const channelA = channelsMap.get(a.channel_id); const channelB = channelsMap.get(b.channel_id); if (!channelA || !channelB) { return 0; // If either channel is undefined, treat as equal } // Priority for channels with an empty team_id if (channelA.team_id === '' && channelB.team_id !== '') { return -1; } if (channelB.team_id === '' && channelA.team_id !== '') { return 1; } // Get team indices for sorting const teamIndexA = teamIndexMap.get(channelA.team_id) ?? Number.MAX_SAFE_INTEGER; const teamIndexB = teamIndexMap.get(channelB.team_id) ?? Number.MAX_SAFE_INTEGER; // Sort by team index first if (teamIndexA !== teamIndexB) { return teamIndexA - teamIndexB; } // If team index is the same, sort by last_viewed_at in descending order return b.last_viewed_at - a.last_viewed_at; }). reduce((acc, member) => { const channel = channelsMap.get(member.channel_id); const teamId = channel!.team_id || ''; if (!acc[teamId]) { acc[teamId] = []; } acc[teamId].push(channel!.id); return acc; }, {} as Record); for (const channelIds of Object.values(sortedUnreadchannelIdsByTeam)) { /* eslint-disable-next-line no-await-in-loop */ await processChannelPostsByTeam(serverUrl, channelIds, false, groupLabel, isCRTEnabled); } }; export async function fetchPosts(serverUrl: string, channelId: string, page = 0, perPage = General.POST_CHUNK_SIZE, fetchOnly = false, groupLabel?: RequestGroupLabel): Promise { try { if (!fetchOnly) { EphemeralStore.addLoadingMessagesForChannel(serverUrl, channelId); } const {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const client = NetworkManager.getClient(serverUrl); const isCRTEnabled = await getIsCRTEnabled(database); const data = await client.getPosts(channelId, page, perPage, isCRTEnabled, isCRTEnabled, groupLabel); const result = processPostsFetched(data); if (!fetchOnly && result.posts.length) { const models = await operator.handlePosts({ ...result, actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, prepareRecordsOnly: true, }); const {authors} = await fetchPostAuthors(serverUrl, result.posts, true, groupLabel); if (authors?.length) { const userModels = await operator.handleUsers({ users: authors, prepareRecordsOnly: true, }); models.push(...userModels); } if (isCRTEnabled) { const threadModels = await prepareThreadsFromReceivedPosts(operator, result.posts, false); if (threadModels?.length) { models.push(...threadModels); } } if (models.length) { await operator.batchRecords(models, 'fetchPosts'); } } return result; } catch (error) { logDebug('error on fetchPosts', getFullErrorMessage(error)); forceLogoutIfNecessary(serverUrl, error); return {error}; } finally { if (!fetchOnly) { EphemeralStore.stopLoadingMessagesForChannel(serverUrl, channelId); } } } export async function fetchPostsBefore(serverUrl: string, channelId: string, postId: string, perPage = General.POST_CHUNK_SIZE, fetchOnly = false) { try { if (!fetchOnly) { EphemeralStore.addLoadingMessagesForChannel(serverUrl, channelId); } const client = NetworkManager.getClient(serverUrl); const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const isCRTEnabled = await getIsCRTEnabled(database); const data = await client.getPostsBefore(channelId, postId, 0, perPage, isCRTEnabled, isCRTEnabled); const result = processPostsFetched(data); if (result.posts.length && !fetchOnly) { try { const models = await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_BEFORE, ...result, prepareRecordsOnly: true, }); const {authors} = await fetchPostAuthors(serverUrl, result.posts, true); if (authors?.length) { const userModels = await operator.handleUsers({ users: authors, prepareRecordsOnly: true, }); models.push(...userModels); } if (isCRTEnabled) { const threadModels = await prepareThreadsFromReceivedPosts(operator, result.posts, false); if (threadModels?.length) { models.push(...threadModels); } } await operator.batchRecords(models, 'fetchPostsBefore'); } catch (error) { logError('FETCH POSTS BEFORE ERROR', error); } } return result; } catch (error) { logDebug('error on fetchPostsBefore', getFullErrorMessage(error)); forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; } finally { if (!fetchOnly) { EphemeralStore.stopLoadingMessagesForChannel(serverUrl, channelId); } } } export async function fetchPostsSince(serverUrl: string, channelId: string, since: number, fetchOnly = false, groupLabel?: RequestGroupLabel): Promise { try { if (!fetchOnly) { EphemeralStore.addLoadingMessagesForChannel(serverUrl, channelId); } const client = NetworkManager.getClient(serverUrl); const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const isCRTEnabled = await getIsCRTEnabled(database); const data = await client.getPostsSince(channelId, since, isCRTEnabled, isCRTEnabled, groupLabel); const result = processPostsFetched(data); if (!fetchOnly) { const models = await operator.handlePosts({ ...result, actionType: ActionType.POSTS.RECEIVED_SINCE, prepareRecordsOnly: true, }); const {authors} = await fetchPostAuthors(serverUrl, result.posts, true, groupLabel); if (authors?.length) { const userModels = await operator.handleUsers({ users: authors, prepareRecordsOnly: true, }); models.push(...userModels); } if (isCRTEnabled) { const threadModels = await prepareThreadsFromReceivedPosts(operator, result.posts, false); if (threadModels?.length) { models.push(...threadModels); } } await operator.batchRecords(models, 'fetchPostsSince'); } return result; } catch (error) { logDebug('error on fetchPostsSince', getFullErrorMessage(error)); forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; } finally { if (!fetchOnly) { EphemeralStore.stopLoadingMessagesForChannel(serverUrl, channelId); } } } export const fetchPostAuthors = async (serverUrl: string, posts: Post[], fetchOnly = false, groupLabel?: RequestGroupLabel): Promise => { try { const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const client = NetworkManager.getClient(serverUrl); const currentUserId = await getCurrentUserId(database); const users = await queryAllUsers(database).fetch(); const existingUserIds = new Set(); const existingUserNames = new Set(); let excludeUsername; users.forEach((u) => { existingUserIds.add(u.id); existingUserNames.add(u.username); if (u.id === currentUserId) { excludeUsername = u.username; } }); const usernamesToLoad = getNeededAtMentionedUsernames(existingUserNames, posts, excludeUsername); const userIdsToLoad = new Set(); for (const p of posts) { const {user_id} = p; if (user_id !== currentUserId) { userIdsToLoad.add(user_id); } } const promises: Array> = []; if (userIdsToLoad.size) { promises.push(client.getProfilesByIds(Array.from(userIdsToLoad), {}, groupLabel)); } if (usernamesToLoad.size) { promises.push(client.getProfilesByUsernames(Array.from(usernamesToLoad), groupLabel)); } if (promises.length) { const authorsResult = await Promise.allSettled(promises); const result = authorsResult.reduce((acc, item) => { if (item.status === 'fulfilled') { acc.push(item.value); } return acc; }, []); const authors = result.flat(); if (!fetchOnly && authors.length) { await operator.handleUsers({ users: authors, prepareRecordsOnly: false, }); } return {authors}; } return {authors: [] as UserProfile[]}; } catch (error) { logDebug('error on fetchPostAuthors', getFullErrorMessage(error)); forceLogoutIfNecessary(serverUrl, error); return {error}; } }; export async function fetchPostThread(serverUrl: string, postId: string, options?: FetchPaginatedThreadOptions, fetchOnly = false, groupLabel?: RequestGroupLabel) { try { setFetchingThreadState(postId, true); const client = NetworkManager.getClient(serverUrl); const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const isCRTEnabled = await getIsCRTEnabled(database); // Not doing any version check as server versions below 6.7 will ignore the additional params from the client. const data = await client.getPostThread(postId, { collapsedThreads: isCRTEnabled, collapsedThreadsExtended: isCRTEnabled, ...options, }, groupLabel); const result = processPostsFetched(data); let posts: Model[] = []; if (result.posts.length && !fetchOnly) { const models: Model[] = []; posts = await operator.handlePosts({ ...result, actionType: ActionType.POSTS.RECEIVED_IN_THREAD, prepareRecordsOnly: true, }); models.push(...posts); const {authors} = await fetchPostAuthors(serverUrl, result.posts, true, groupLabel); if (authors?.length) { const userModels = await operator.handleUsers({ users: authors, prepareRecordsOnly: true, }); models.push(...userModels); } if (isCRTEnabled) { const threadModels = await prepareThreadsFromReceivedPosts(operator, result.posts, true); if (threadModels?.length) { models.push(...threadModels); } } await operator.batchRecords(models, 'fetchPostThread'); } setFetchingThreadState(postId, false); return {posts: result.posts}; } catch (error) { logDebug('error on fetchPostThread', getFullErrorMessage(error)); forceLogoutIfNecessary(serverUrl, error); setFetchingThreadState(postId, false); return {error}; } } export async function fetchPostsAround(serverUrl: string, channelId: string, postId: string, perPage = General.POST_AROUND_CHUNK_SIZE, isCRTEnabled = false) { try { const client = NetworkManager.getClient(serverUrl); const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const [after, post, before] = await Promise.all([ client.getPostsAfter(channelId, postId, 0, perPage, isCRTEnabled, isCRTEnabled), client.getPostThread(postId, { collapsedThreads: isCRTEnabled, collapsedThreadsExtended: isCRTEnabled, fetchAll: true, }), client.getPostsBefore(channelId, postId, 0, perPage, isCRTEnabled, isCRTEnabled), ]); const preData: PostResponse = { posts: { ...filterPostsInOrderedArray(after.posts, after.order), [postId]: post.posts![postId], ...filterPostsInOrderedArray(before.posts, before.order), }, order: [], }; const data = processPostsFetched(preData); let posts: Model[] = []; const models: Model[] = []; if (data.posts?.length) { try { const {authors} = await fetchPostAuthors(serverUrl, data.posts, true); if (authors?.length) { const userModels = await operator.handleUsers({ users: authors, prepareRecordsOnly: true, }); models.push(...userModels); } } catch (error) { logError('FETCH AUTHORS ERROR', error); } posts = await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_AROUND, ...data, prepareRecordsOnly: true, }); models.push(...posts); if (isCRTEnabled) { const threadModels = await prepareThreadsFromReceivedPosts(operator, data.posts, false); if (threadModels?.length) { models.push(...threadModels); } } await operator.batchRecords(models, 'fetchPostsAround'); } return {posts: data.posts}; } catch (error) { logDebug('error on fetchPostsAround', getFullErrorMessage(error)); forceLogoutIfNecessary(serverUrl, error); return {error}; } } export async function fetchMissingChannelsFromPosts(serverUrl: string, posts: Post[], fetchOnly = false) { try { const client = NetworkManager.getClient(serverUrl); const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const channelIds = new Set(await queryAllMyChannel(database).fetchIds()); const channelPromises: Array> = []; const userPromises: Array> = []; posts.forEach((post) => { const id = post.channel_id; if (!channelIds.has(id)) { channelPromises.push(client.getChannel(id)); userPromises.push(client.getMyChannelMember(id)); } }); const channels = await Promise.all(channelPromises); const channelMemberships = await Promise.all(userPromises); if (!fetchOnly && channels.length && channelMemberships.length) { const isCRTEnabled = await getIsCRTEnabled(database); const modelPromises = prepareMissingChannelsForAllTeams(operator, channels, channelMemberships, isCRTEnabled); if (modelPromises.length) { const channelModelsArray = await Promise.all(modelPromises); if (channelModelsArray.length) { const models = channelModelsArray.flatMap((mdls) => { if (!mdls || mdls.length) { return []; } return mdls; }); if (models.length) { await operator.batchRecords(models, 'fetchMissingChannelsFromPosts'); } } } } return { channels, channelMemberships, }; } catch (error) { logDebug('error on fetchMissingChannelsFromPosts', getFullErrorMessage(error)); forceLogoutIfNecessary(serverUrl, error); return {error}; } } export async function fetchPostById(serverUrl: string, postId: string, fetchOnly = false, groupLabel?: RequestGroupLabel) { try { const client = NetworkManager.getClient(serverUrl); const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const post = await client.getPost(postId, groupLabel); if (!fetchOnly) { const models: Model[] = []; const {authors} = await fetchPostAuthors(serverUrl, [post], true, groupLabel); const posts = await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_NEW, order: [post.id], posts: [post], prepareRecordsOnly: true, }); models.push(...posts); if (authors?.length) { const users = await operator.handleUsers({ users: authors, prepareRecordsOnly: true, }); models.push(...users); } const isCRTEnabled = await getIsCRTEnabled(database); if (isCRTEnabled) { const threadModels = await prepareThreadsFromReceivedPosts(operator, [post], false); if (threadModels?.length) { models.push(...threadModels); } } await operator.batchRecords(models, 'fetchPostById'); } return {post}; } catch (error) { logDebug('error on fetchPostById', getFullErrorMessage(error)); forceLogoutIfNecessary(serverUrl, error); return {error}; } } export const togglePinPost = async (serverUrl: string, postId: string) => { try { const client = NetworkManager.getClient(serverUrl); const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const post = await getPostById(database, postId); if (post) { const isPinned = post.isPinned; const request = isPinned ? client.unpinPost : client.pinPost; await request(postId); await database.write(async () => { await post.update((p) => { p.isPinned = !isPinned; }); }); } return {post}; } catch (error) { logDebug('error on togglePinPost', getFullErrorMessage(error)); forceLogoutIfNecessary(serverUrl, error); return {error}; } }; export const deletePost = async (serverUrl: string, postToDelete: PostModel | Post) => { try { const client = NetworkManager.getClient(serverUrl); if (postToDelete.type === Post.POST_TYPES.COMBINED_USER_ACTIVITY && postToDelete.props?.system_post_ids) { const systemPostIds = getPostIdsForCombinedUserActivityPost(postToDelete.id); const promises = systemPostIds.map((id) => client.deletePost(id)); await Promise.all(promises); } else { await client.deletePost(postToDelete.id); } const post = await removePost(serverUrl, postToDelete); return {post}; } catch (error) { logDebug('error on deletePost', getFullErrorMessage(error)); forceLogoutIfNecessary(serverUrl, error); return {error}; } }; export const burnPostNow = async (serverUrl: string, postToBurn: PostModel | Post) => { if (!isBoRPost(postToBurn)) { return {error: 'Post is not a Burn-on-Read post'}; } try { const client = NetworkManager.getClient(serverUrl); await client.burnPostNow(postToBurn.id); const post = await removePost(serverUrl, postToBurn); return {post}; } catch (error) { logDebug('error on burnPostNow', getFullErrorMessage(error)); forceLogoutIfNecessary(serverUrl, error); return {error}; } }; export const markPostAsUnread = async (serverUrl: string, postId: string) => { try { const client = NetworkManager.getClient(serverUrl); const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const [userId, post] = await Promise.all([getCurrentUserId(database), getPostById(database, postId)]); if (post && userId) { await client.markPostAsUnread(userId, postId); const {channelId} = post; const [channel, channelMember] = await Promise.all([ client.getChannel(channelId), client.getChannelMember(channelId, userId), ]); if (channel && channelMember) { const isCRTEnabled = await getIsCRTEnabled(database); let totalMessages = channel.total_msg_count; let messages = channelMember.msg_count; let mentionCount = channelMember.mention_count; if (isCRTEnabled) { totalMessages = channel.total_msg_count_root!; messages = channelMember.msg_count_root!; mentionCount = channelMember.mention_count_root!; } const messageCount = totalMessages - messages; await markChannelAsUnread(serverUrl, channelId, messageCount, mentionCount, post.createAt); return {post}; } } return {post}; } catch (error) { logDebug('error on markPostAsUnread', getFullErrorMessage(error)); forceLogoutIfNecessary(serverUrl, error); return {error}; } }; export const editPost = async (serverUrl: string, postId: string, postMessage: string, file_ids: string[], removed_file_ids: string[]) => { try { const client = NetworkManager.getClient(serverUrl); const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const post = await getPostById(database, postId); if (post) { const originalFiles = await queryFilesForPost(database, postId).fetch(); const originalFileIds = originalFiles.map((f) => f.id); const {update_at, edit_at, message: updatedMessage, message_source} = await client.patchPost({message: postMessage, id: postId, file_ids}); await database.write(async () => { await post.update((p) => { p.updateAt = update_at; p.editAt = edit_at; p.message = updatedMessage; p.messageSource = message_source || ''; }); }); if (removed_file_ids.length > 0) { const filesToDelete = await getFilesByIds(database, removed_file_ids); if (filesToDelete.length > 0) { const fileDeleteModels = filesToDelete.map((file) => { file.prepareDestroyPermanently(); return file; }); await operator.batchRecords(fileDeleteModels, 'delete files'); } } const filesChanged = hasArrayChanged(originalFileIds, file_ids); if (filesChanged && post.channelId) { const channelId = post.channelId; fetchChannelStats(serverUrl, channelId); } } return {post}; } catch (error) { logDebug('error on editPost', getFullErrorMessage(error)); forceLogoutIfNecessary(serverUrl, error); return {error}; } }; export const revealBoRPost = async (serverUrl: string, postId: string) => { try { const client = NetworkManager.getClient(serverUrl); const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const post = await getPostById(database, postId); if (!post) { return {post: undefined}; } const revealedPost = await client.revealBoRPost(postId); await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, order: [revealedPost.id], posts: [revealedPost], prepareRecordsOnly: false, }); return {post}; } catch (error) { logDebug('error on revealBoRPost', getFullErrorMessage(error)); forceLogoutIfNecessary(serverUrl, error); return {error}; } }; export async function fetchSavedPosts(serverUrl: string, teamId?: string, channelId?: string, page?: number, perPage?: number) { try { const client = NetworkManager.getClient(serverUrl); const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const userId = await getCurrentUserId(database); const data = await client.getSavedPosts(userId, channelId, teamId, page, perPage); const posts = data.posts || {}; const order = data.order || []; const postsArray = order.map((id) => posts[id]); if (!postsArray.length) { return { order, posts: postsArray, }; } const promises: Array> = []; const {authors} = await fetchPostAuthors(serverUrl, postsArray, true); const {channels, channelMemberships} = await fetchMissingChannelsFromPosts(serverUrl, postsArray, true); if (authors?.length) { promises.push( operator.handleUsers({ users: authors, prepareRecordsOnly: true, }), ); } if (channels?.length && channelMemberships?.length) { const isCRTEnabled = await getIsCRTEnabled(database); const channelPromises = prepareMissingChannelsForAllTeams(operator, channels, channelMemberships, isCRTEnabled); if (channelPromises.length) { promises.push(...channelPromises); } } promises.push( operator.handlePosts({ actionType: '', order: [], posts: postsArray, previousPostId: '', prepareRecordsOnly: true, }), ); const isCRTEnabled = await getIsCRTEnabled(database); if (isCRTEnabled) { promises.push(prepareThreadsFromReceivedPosts(operator, postsArray, false)); } const modelArrays = await Promise.all(promises); const models = modelArrays.flatMap((mdls) => { if (!mdls || !mdls.length) { return []; } return mdls; }); await operator.batchRecords(models, 'fetchSavedPosts'); return { order, posts: postsArray, }; } catch (error) { logDebug('error on fetchSavedPosts', getFullErrorMessage(error)); forceLogoutIfNecessary(serverUrl, error); return {error}; } } export async function fetchPinnedPosts(serverUrl: string, channelId: string) { try { const client = NetworkManager.getClient(serverUrl); const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const data = await client.getPinnedPosts(channelId); const posts = data.posts || {}; const order = data.order || []; const postsArray = order.map((id) => posts[id]); if (!postsArray.length) { return { order, posts: postsArray, }; } const promises: Array> = []; const isCRTEnabled = await getIsCRTEnabled(database); const {authors} = await fetchPostAuthors(serverUrl, postsArray, true); const {channels, channelMemberships} = await fetchMissingChannelsFromPosts(serverUrl, postsArray, true); if (authors?.length) { promises.push( operator.handleUsers({ users: authors, prepareRecordsOnly: true, }), ); } if (channels?.length && channelMemberships?.length) { const channelPromises = prepareMissingChannelsForAllTeams(operator, channels, channelMemberships, isCRTEnabled); if (channelPromises.length) { promises.push(...channelPromises); } } promises.push( operator.handlePosts({ actionType: '', order: [], posts: postsArray, previousPostId: '', prepareRecordsOnly: true, }), ); if (isCRTEnabled) { promises.push(prepareThreadsFromReceivedPosts(operator, postsArray, false)); } const modelArrays = await Promise.all(promises); const models = modelArrays.flatMap((mdls) => { if (!mdls || !mdls.length) { return []; } return mdls; }); await operator.batchRecords(models, 'fetchPinnedPosts'); return { order, posts: postsArray, }; } catch (error) { logDebug('error on fetchPinnedPosts', getFullErrorMessage(error)); forceLogoutIfNecessary(serverUrl, error); return {error}; } } export async function acknowledgePost(serverUrl: string, postId: string) { try { const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const client = NetworkManager.getClient(serverUrl); EphemeralStore.setAcknowledgingPost(postId); const userId = await getCurrentUserId(database); const {acknowledged_at: acknowledgedAt} = await client.acknowledgePost(postId, userId); return addPostAcknowledgement(serverUrl, postId, userId, acknowledgedAt, false); } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; } finally { EphemeralStore.unsetAcknowledgingPost(postId); } } export async function unacknowledgePost(serverUrl: string, postId: string) { try { const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const client = NetworkManager.getClient(serverUrl); EphemeralStore.setUnacknowledgingPost(postId); const userId = await getCurrentUserId(database); await client.unacknowledgePost(postId, userId); return removePostAcknowledgement(serverUrl, postId, userId, false); } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; } finally { EphemeralStore.unsetUnacknowledgingPost(postId); } }