// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. // /* eslint-disable max-lines */ import {DeviceEventEmitter} from 'react-native'; import {markChannelAsUnread, updateLastPostAt} from '@actions/local/channel'; import {processPostsFetched, removePost} from '@actions/local/post'; import {addRecentReaction} from '@actions/local/reactions'; import {ActionType, Events, General, Post, ServerErrors} from '@constants'; import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import {filterPostsInOrderedArray} from '@helpers/api/post'; import {getNeededAtMentionedUsernames} from '@helpers/api/user'; import {extractRecordsForTable} from '@helpers/database'; import NetworkManager from '@init/network_manager'; import {prepareMissingChannelsForAllTeams, queryAllMyChannel} from '@queries/servers/channel'; import {queryAllCustomEmojis} from '@queries/servers/custom_emoji'; import {getPostById, getRecentPostsInChannel} from '@queries/servers/post'; import {getCurrentUserId, getCurrentChannelId} from '@queries/servers/system'; import {queryAllUsers} from '@queries/servers/user'; import {getValidEmojis, matchEmoticons} from '@utils/emoji/helpers'; import {getPostIdsForCombinedUserActivityPost} from '@utils/post_list'; 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; } type PostsObjectsRequest = { error?: unknown; order?: string[]; posts?: IDMappedObjects; previousPostId?: string; } type AuthorsRequest = { authors?: UserProfile[]; error?: unknown; } export const createPost = async (serverUrl: string, post: Partial, files: FileInfo[] = []): Promise<{data?: boolean; error?: any}> => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return {error: `${serverUrl} database not found`}; } let client: Client; try { client = NetworkManager.getClient(serverUrl); } catch (error) { return {error}; } const currentUserId = await getCurrentUserId(operator.database); const timestamp = Date.now(); const pendingPostId = post.pending_post_id || `${currentUserId}:${timestamp}`; const existing = await getPostById(operator.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[] = []; const filesModels = await operator.handleFiles({files, prepareRecordsOnly: true}); if (filesModels.length) { initialPostModels.push(...filesModels); } const postModels = await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_NEW, order: [databasePost.id], posts: [databasePost], prepareRecordsOnly: true, }); if (postModels.length) { initialPostModels.push(...postModels); } const customEmojis = await queryAllCustomEmojis(operator.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); } operator.batchRecords(initialPostModels); try { const created = await client.createPost(newPost); await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_NEW, order: [created.id], posts: [created], }); newPost = created; } catch (error: any) { 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 (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 { await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_NEW, order: [errorPost.id], posts: [errorPost], }); } } return {data: true}; }; export const fetchPostsForCurrentChannel = async (serverUrl: string) => { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (!database) { return {error: `${serverUrl} database not found`}; } const currentChannelId = await getCurrentChannelId(database); return fetchPostsForChannel(serverUrl, currentChannelId); }; export const fetchPostsForChannel = async (serverUrl: string, channelId: string, fetchOnly = false) => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return {error: `${serverUrl} database not found`}; } let postAction: Promise|undefined; let actionType: string|undefined; const postsInChannel = await getRecentPostsInChannel(operator.database, channelId); if (!postsInChannel || postsInChannel.length < General.POST_CHUNK_SIZE) { postAction = fetchPosts(serverUrl, channelId, 0, General.POST_CHUNK_SIZE, true); actionType = ActionType.POSTS.RECEIVED_IN_CHANNEL; } else { const since = postsInChannel[0]?.createAt || 0; postAction = fetchPostsSince(serverUrl, channelId, since, true); actionType = ActionType.POSTS.RECEIVED_SINCE; } const data = await postAction; if (data.error) { // Here we should emit an event that fetching posts failed. } let authors: UserProfile[] = []; if (data.posts?.length && data.order?.length) { try { const {authors: fetchedAuthors} = await fetchPostAuthors(serverUrl, data.posts, true); authors = fetchedAuthors || []; } catch (error) { // eslint-disable-next-line no-console console.log('FETCH AUTHORS ERROR', error); } if (!fetchOnly) { const models = []; const postModels = await operator.handlePosts({ actionType, order: data.order, posts: data.posts, previousPostId: data.previousPostId, prepareRecordsOnly: true, }); if (postModels) { models.push(...postModels); } if (authors.length) { const userModels = await operator.handleUsers({users: authors, prepareRecordsOnly: true}); if (userModels.length) { models.push(...userModels); } } let lastPostAt = 0; for (const post of data.posts) { lastPostAt = post.create_at > lastPostAt ? post.create_at : lastPostAt; } const {member: memberModel} = await updateLastPostAt(serverUrl, channelId, lastPostAt, true); if (memberModel) { models.push(memberModel); } if (models.length) { await operator.batchRecords(models); } } } return {posts: data.posts, order: data.order, authors, actionType, previousPostId: data.previousPostId}; }; export const fetchPostsForUnreadChannels = async (serverUrl: string, channels: Channel[], memberships: ChannelMembership[], excludeChannelId?: string) => { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (!database) { return {error: `${serverUrl} database not found`}; } try { for (const member of memberships) { const channel = channels.find((c) => c.id === member.channel_id); if (channel && (channel.total_msg_count - member.msg_count) > 0 && channel.id !== excludeChannelId) { fetchPostsForChannel(serverUrl, channel.id); } } } catch (error) { return {error}; } return {error: undefined}; }; export const fetchPosts = async (serverUrl: string, channelId: string, page = 0, perPage = General.POST_CHUNK_SIZE, fetchOnly = false): Promise => { let client: Client; try { client = NetworkManager.getClient(serverUrl); } catch (error) { return {error}; } try { const data = await client.getPosts(channelId, page, perPage); return processPostsFetched(serverUrl, ActionType.POSTS.RECEIVED_IN_CHANNEL, data, fetchOnly); } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; } }; export const fetchPostsBefore = async (serverUrl: string, channelId: string, postId: string, perPage = General.POST_CHUNK_SIZE, fetchOnly = false) => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return {error: `${serverUrl} database not found`}; } let client: Client; try { client = NetworkManager.getClient(serverUrl); } catch (error) { return {error}; } const activeServerUrl = await DatabaseManager.getActiveServerUrl(); try { if (activeServerUrl === serverUrl) { DeviceEventEmitter.emit(Events.LOADING_CHANNEL_POSTS, true); } const data = await client.getPostsBefore(channelId, postId, 0, perPage); const result = await processPostsFetched(serverUrl, ActionType.POSTS.RECEIVED_BEFORE, data, true); if (activeServerUrl === serverUrl) { DeviceEventEmitter.emit(Events.LOADING_CHANNEL_POSTS, false); } 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); } await operator.batchRecords(models); } catch (error) { // eslint-disable-next-line no-console console.log('FETCH AUTHORS ERROR', error); } } return result; } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); if (activeServerUrl === serverUrl) { DeviceEventEmitter.emit(Events.LOADING_CHANNEL_POSTS, true); } return {error}; } }; export const fetchPostsSince = async (serverUrl: string, channelId: string, since: number, fetchOnly = false): Promise => { let client: Client; try { client = NetworkManager.getClient(serverUrl); } catch (error) { return {error}; } try { const data = await client.getPostsSince(channelId, since); return processPostsFetched(serverUrl, ActionType.POSTS.RECEIVED_SINCE, data, fetchOnly); } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; } }; export const fetchPostAuthors = async (serverUrl: string, posts: Post[], fetchOnly = false): Promise => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return {error: `${serverUrl} database not found`}; } let client: Client; try { client = NetworkManager.getClient(serverUrl); } catch (error) { return {error}; } const currentUserId = await getCurrentUserId(operator.database); const users = await queryAllUsers(operator.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(); posts.forEach((p) => { const userId = p.user_id; if (userId === currentUserId) { return; } if (!existingUserIds.has(userId)) { userIdsToLoad.add(userId); } }); try { const promises: Array> = []; if (userIdsToLoad.size) { promises.push(client.getProfilesByIds(Array.from(userIdsToLoad))); } if (usernamesToLoad.size) { promises.push(client.getProfilesByUsernames(Array.from(usernamesToLoad))); } if (promises.length) { const result = await Promise.all(promises); const authors = result.flat(); if (!fetchOnly && authors.length) { await operator.handleUsers({ users: authors, prepareRecordsOnly: false, }); } return {authors}; } return {authors: [] as UserProfile[]}; } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; } }; export const fetchPostThread = async (serverUrl: string, postId: string, fetchOnly = false): Promise => { let client: Client; try { client = NetworkManager.getClient(serverUrl); } catch (error) { return {error}; } try { const data = await client.getPostThread(postId); return processPostsFetched(serverUrl, ActionType.POSTS.RECEIVED_IN_THREAD, data, fetchOnly); } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; } }; export async function fetchPostsAround(serverUrl: string, channelId: string, postId: string, perPage = General.POST_AROUND_CHUNK_SIZE) { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return {error: `${serverUrl} database not found`}; } let client: Client; try { client = NetworkManager.getClient(serverUrl); } catch (error) { return {error}; } try { const [after, post, before] = await Promise.all([ client.getPostsAfter(channelId, postId, 0, perPage), client.getPostThread(postId), client.getPostsBefore(channelId, postId, 0, perPage), ]); const preData: PostResponse = { posts: { ...filterPostsInOrderedArray(after.posts, after.order), postId: post.posts![postId], ...filterPostsInOrderedArray(before.posts, before.order), }, order: [], }; const data = await processPostsFetched(serverUrl, ActionType.POSTS.RECEIVED_AROUND, preData, true); 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) { // eslint-disable-next-line no-console console.error('FETCH AUTHORS ERROR', error); } posts = await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_AROUND, ...data, prepareRecordsOnly: true, }); models.push(...posts); await operator.batchRecords(models); } return {posts: extractRecordsForTable(posts, MM_TABLES.SERVER.POST)}; } catch (error) { // eslint-disable-next-line no-console console.error('FETCH POSTS AROUND ERROR', error); forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; } } export const postActionWithCookie = async (serverUrl: string, postId: string, actionId: string, actionCookie: string, selectedOption = '') => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return {error: `${serverUrl} database not found`}; } let client: Client; try { client = NetworkManager.getClient(serverUrl); } catch (error) { return {error}; } try { const data = await client.doPostActionWithCookie(postId, actionId, actionCookie, selectedOption); if (data?.trigger_id) { await operator.handleSystem({ systems: [{ id: SYSTEM_IDENTIFIERS.INTEGRATION_TRIGGER_ID, value: data.trigger_id, }], prepareRecordsOnly: false, }); } return {data}; } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; } }; export async function fetchMissingChannelsFromPosts(serverUrl: string, posts: Post[], fetchOnly = false) { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return {error: `${serverUrl} database not found`}; } let client: Client; try { client = NetworkManager.getClient(serverUrl); } catch (error) { return {error}; } try { const channelIds = await queryAllMyChannel(operator.database).fetchIds(); const channelPromises: Array> = []; const userPromises: Array> = []; posts.forEach((post) => { const id = post.channel_id; if (channelIds.indexOf(id) === -1) { 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 modelPromises = prepareMissingChannelsForAllTeams(operator, channels, channelMemberships) as Array>; if (modelPromises && 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) { operator.batchRecords(models); } } } } return { channels, channelMemberships, }; } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; } } export const fetchPostById = async (serverUrl: string, postId: string, fetchOnly = false) => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return {error: `${serverUrl} database not found`}; } let client: Client; try { client = NetworkManager.getClient(serverUrl); } catch (error) { return {error}; } try { const post = await client.getPost(postId); if (!fetchOnly) { const models: Model[] = []; const {authors} = await fetchPostAuthors(serverUrl, [post], true); 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: false, }); models.push(...users); } await operator.batchRecords(models); } return {post}; } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; } }; export const togglePinPost = async (serverUrl: string, postId: string) => { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (!database) { return {error: `${serverUrl} database not found`}; } let client: Client; try { client = NetworkManager.getClient(serverUrl); } catch (error) { return {error}; } try { 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) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; } }; export const deletePost = async (serverUrl: string, postToDelete: PostModel | Post) => { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (!database) { return {error: `${serverUrl} database not found`}; } let client: Client; try { client = NetworkManager.getClient(serverUrl); } catch (error) { return {error}; } try { 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) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; } }; export const markPostAsUnread = async (serverUrl: string, postId: string) => { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (!database) { return {error: `${serverUrl} database not found`}; } let client; try { client = NetworkManager.getClient(serverUrl); } catch (error) { return {error}; } try { 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 messageCount = channel.total_msg_count - channelMember.msg_count; const mentionCount = channelMember.mention_count; await markChannelAsUnread(serverUrl, channelId, messageCount, mentionCount, post.createAt); return { post, }; } } return { post, }; } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; } }; export const editPost = async (serverUrl: string, postId: string, postMessage: string) => { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (!database) { return {error: `${serverUrl} database not found`}; } let client; try { client = NetworkManager.getClient(serverUrl); } catch (error) { return {error}; } try { const post = await getPostById(database, postId); if (post) { const {update_at, edit_at, message: updatedMessage} = await client.patchPost({message: postMessage, id: postId}); await database.write(async () => { await post.update((p) => { p.updateAt = update_at; p.editAt = edit_at; p.message = updatedMessage; }); }); } return { post, }; } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; } }; export async function fetchSavedPosts(serverUrl: string, teamId?: string, channelId?: string, page?: number, perPage?: number) { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return {error: `${serverUrl} database not found`}; } let client; try { client = NetworkManager.getClient(serverUrl); } catch (error) { return {error}; } try { const userId = await getCurrentUserId(operator.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) as {channels: Channel[]; channelMemberships: ChannelMembership[]}; if (authors?.length) { promises.push( operator.handleUsers({ users: authors, prepareRecordsOnly: true, }), ); } if (channels?.length && channelMemberships?.length) { const channelPromises = prepareMissingChannelsForAllTeams(operator, channels, channelMemberships) as Array>; if (channelPromises && channelPromises.length) { promises.push(...channelPromises); } } promises.push( operator.handlePosts({ actionType: '', order: [], posts: postsArray, previousPostId: '', prepareRecordsOnly: true, }), ); const modelArrays = await Promise.all(promises); const models = modelArrays.flatMap((mdls) => { if (!mdls || !mdls.length) { return []; } return mdls; }); if (models.length) { await operator.batchRecords(models); } return { order, posts: postsArray, }; } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; } }