mattermost-mobile-upstream/app/actions/remote/post.ts
JG Heithcock 9e54b8fc46
[MM-67889] Fix permalinks to unjoined channels (#9624)
* [MM-67889] Fix permalinks to unjoined channels

Tapping a permalink to a post in a channel the user hasn't joined shows
"Message not viewable" instead of a join prompt. The root cause is that
GET /posts/{id} returns 403 for non-members, so fetchPostById fails and
the code never reaches the join flow.

Fall back to GET /posts/{id}/info (getPostInfo) when fetchPostById fails.
This endpoint returns channel metadata without requiring membership,
matching the web client's focusPost approach.

* Add debug logging for errors as per request

* Move getPostInfo call to remote action layer

Extract the direct NetworkManager.getClient().getPostInfo() call from
the Permalink component into a fetchPostInfo remote action in
app/actions/remote/post.ts, following the project convention that
components should not call NetworkManager directly.

* Call getPostInfo before speculatively joining team

Restructure the permalink flow so that getPostInfo (GET /posts/{id}/info)
runs first, before any team join. When the endpoint succeeds and the user
hasn't joined the channel, the join UI is shown immediately with no side
effects. Team and channel joins now happen only when the user clicks
"Join", via a new `needsTeamJoin` flag on PermalinkErrorType.
The old speculative-team-join path is preserved as a fallback for servers
that predate the /info endpoint.
Adds unit tests for the new fetchPostInfo remote action.

* Address review feedback: localize alerts, version-guard fetchPostInfo, pass database as prop

* Fix unused parameter lint error in fetchPostInfo mock
2026-04-06 00:08:53 -07:00

1202 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; 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<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 fetchPostInfo(serverUrl: string, postId: string): Promise<{postInfo?: PostInfo; error?: unknown}> {
try {
const client = NetworkManager.getClient(serverUrl);
const postInfo = await client.getPostInfo(postId);
return {postInfo};
} catch (error) {
logDebug('error on fetchPostInfo', 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);
}
}