mattermost-mobile/app/actions/remote/post.ts
Harshil Sharma 04de7b9745
Bor create post (#9393)
* Initial setup for new keyboard

* fix the offset calculation onMove by adding isKeyboardFullyOpened

* Done with the keyboard handling implementation

* Handled keyboard focus and blured state using context

* Added default height for input container

* Android support

* Tablet state handling

* Fix for refreshing offset in list

* Created a default context for mention post list

* Fix linter errors

* Fix tests

* Minor

* Fix the height issue for tablet view

* Review comments

* Dependency fix

* Reveiw comment

* keyboard animation only enabled with screen on top navigation

* added tests

* Added extra keyboard component for emoji picker (#9328)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* scroll to bottom visible on emoji picker

* Check for stale event for keyboard geature area

* fix keyboard stale event for mid-gesture change swipe direction

* Closing emoji picker when message priority is opened

* changing to thread view closes emoji picker

* Close emoij picker and keyboard when attachment icons are clicked

* handle android emoji picker behaviour

* Remove emoji picker code

* fix tests

* Address reviev comments

* Test fixes

* usePreventDoubleTab for race condition and corrected comment on isInputAccessoryViewMode flag

* File attachments option in bottom sheet quick action (#9331)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* File attachments option in bottom sheet quick action

* Updated tests

* i18n

* fix test

* Added tests

* Review comment

* fix tests

* Integrated Emoji picker to Extra keyboard component.  (#9339)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* scroll to bottom visible on emoji picker

* Fix the post input container height change issue

* Added emoji picker with search functionality

* keyboard height not recorded fallback to default height, set search to false closing emoji picker

* fix search funcitonality in android

* scroll to end bottom dismissed with pressed

* Fixed the scroll issue for android

* fix the flickering post input issue

* Wired up the emoji picker

* Added keyboard and spaceback in emoji picker

* intl extract

* initial cursor position and simple calculation review comment

* separated grapheme to utils file

* Review comments

* nitpick

* Fix the bottom safe area and navigation stack restore fix

* Fix test

* disabled emoji picker in edit post screen

* change the name of the variable to name it clarier

* fix the tab gap issue in andriod

* disabled the emoji skin tone change on andriod

* fix the input container jump issue

* fix the failing test

* UX review

* added white space after the attachment icon

* When @ or / is press open keyboard on andriod also fix the scroll issue when keyboard is open

* back bottom closes emoji picker and opening keyboard jump fix

* reverted code

* fixed the flickering issue of input and scroll position fix

* Added BoR button

* Fix the gap issue in thread

* fix the warning reaminated issue when opening a channel

* Fix the extra space issue between input and emoij picker

* Minor fix for extra space between input and emoji picker

* WIP

* Fix thread view bottom safe area and gap between keyboard and searched emojis

* WIP

* refactor: make borConfig optional in BoRAction component

* refactor: remove optional borConfig and simplify state initialization

* Fix the keyboard issue in tablet

* Fix the warning issue react-native-keyboard-controller

* Fix tests

* Fix the tablet search hight issue

* Replaced the ExtraKeyboardProvider with KeyboardProvider from rnkc for permalink

* WIP

* Successfully toggeled bor status

* Displayed bor chip in draft editor

* Send bor post

* Displayed bor chip in draft editor with wrapping

* Added read receipt

* Bottom sheet jump issue resolved

* Search do not close after selecting emoji in search list

* Added the bottom inset height in search emoji for android

* integrated bor receipt count

* fix buid issue

* Added margin

* Handled updating receipts on sync

* test: add comprehensive tests for updateDraftBoRConfig

* test: add initial test file for burn on read label component

* test: add tests for BoRLabel component

* test: update BoRLabel tests to use toBeVisible and remove formatTime mock

* test: update BoR label tests to use renderWithIntl

* refactor: remove getBaseProps and specify props inline in BoRLabel tests

* refactor: improve test component formatting for readability

* test: add validation for BoRLabel time display formats

* test: add comprehensive tests for Burst on Read (BoR) functionality

* WIP

* test: add unit tests for BOR quick action component

* test: add comprehensive tests for BoR quick action component

* test: add missing props to QuickActions test setup

* test: add tests for BoR quick action rendering

* test: mock BoRQuickAction component for testing

* test: add comprehensive tests for observeIsBoREnabled function

* test: add initial test file for BOR read receipts screen

* test: add comprehensive tests for BORReadReceipts component

* test: add test cases for BOR read receipts display

* test: add comprehensive tests for humanReadable format in formatTime

* Added tests

* lint fix

* cleanup

* cleanup

* cleanup

* cleanup

* cleanup

* i18n fix

* Fix the cancel state to emoji picker

* Prevented displaying BoR quick action in threads

* Fixed a test

* lint fix

* review fixes

* review fixes

* i18n fix

* fixed tests

* review fixes

* lint fix

* restored podfile

* Fixed WS event handling for receiving bor post receipnt

* Fixed a test

* fixed TS

* Implemented burn post now for sender and receiver (#9401)

* Implemented burn post now for sender and receiver

* test: add tests for burnPostNow function

* test: add test for delete post option

* test: add comprehensive tests for DeletePostOption component

* Added tests

* Lint fix

* minor improvemenmts

* lint fix

* reset unintended changes

* reset unintended changes

* Bor delete when read by all (#9407)

* WIP

* Updated for all read

* cleanup

* test: add comprehensive tests for handleBoRPostAllRevealed

* test: add tests for BOR events in websocket event handler

* feat: add comprehensive tests for shouldUpdateForBoRPost function

* Added tests

* Lint fix

* fixed TS

* fixed padding

* UX fixes

---------

Co-authored-by: Rajat Dabade <a-rajat.dabade@mattermost.com>
Co-authored-by: Rajat Dabade <rajatdabade1997@gmail.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Rahim Rahman <rahim.rahman@mattermost.com>
2026-02-09 17:00:54 +05:30

1189 lines
44 KiB
TypeScript

// 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<Post>;
previousPostId?: string;
}
type AuthorsRequest = {
authors?: UserProfile[];
error?: unknown;
}
export async function createPost(serverUrl: string, post: Partial<Post>, files: FileInfo[] = []): Promise<{data?: boolean; 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};
}
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<PostsForChannel> {
try {
if (!fetchOnly) {
EphemeralStore.addLoadingMessagesForChannel(serverUrl, channelId);
}
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
let postAction: Promise<PostsRequest>|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<void> => {
const teamIndexMap = new Map<string, number>();
teams.forEach((team, index) => teamIndexMap.set(team.id, index));
const channelsMap = new Map<string, Channel>();
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<string, string[]>);
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<PostsRequest> {
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<PostsRequest> {
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<AuthorsRequest> => {
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<string>();
const existingUserNames = new Set<string>();
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<string>();
for (const p of posts) {
const {user_id} = p;
if (user_id !== currentUserId) {
userIdsToLoad.add(user_id);
}
}
const promises: Array<Promise<UserProfile[]>> = [];
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<UserProfile[][]>((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<PostsObjectsRequest>([
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<Promise<Channel>> = [];
const userPromises: Array<Promise<ChannelMembership>> = [];
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<Promise<Model[]>> = [];
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<Promise<Model[]>> = [];
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);
}
}