mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-11 13:40:12 +00:00
1420 lines
56 KiB
TypeScript
1420 lines
56 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
//
|
|
|
|
/* eslint-disable max-lines */
|
|
|
|
import {deletePostsForChannel, markChannelAsUnread, updateLastPostAt} from '@actions/local/channel';
|
|
import {addPostAcknowledgement, removePost, removePostAcknowledgement, storePostsForChannel} from '@actions/local/post';
|
|
import {addRecentReaction} from '@actions/local/reactions';
|
|
import {captureRedactionEpoch, getRequiredRedactionEpoch} from '@actions/local/redaction';
|
|
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, queryFilesForPosts} from '@queries/servers/file';
|
|
import {getPostById, getRecentPostsInChannel, queryPostsInChannel} from '@queries/servers/post';
|
|
import {getCurrentUserId, isRedactionEnforced} 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 RenderPermissionsStore from '@store/render_permissions_store';
|
|
import {isBoRPost} from '@utils/bor';
|
|
import {expireChannelDecisionsOnDenial} from '@utils/channel_policy';
|
|
import {getValidEmojis, matchEmoticons} from '@utils/emoji/helpers';
|
|
import {getFullErrorMessage, isServerError} from '@utils/errors';
|
|
import {hasArrayChanged} from '@utils/helpers';
|
|
import {logDebug, logError, logWarning} from '@utils/log';
|
|
import {isPostPendingOrFailed, isPostRedactionVerified, 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 {Database} from '@nozbe/watermelondb';
|
|
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;
|
|
|
|
// Dispatch-time epoch, so a fetchOnly caller committing the models itself stamps the same value.
|
|
// Undefined when ABAC is not enforced.
|
|
redactionVerifiedEpoch?: number;
|
|
}
|
|
|
|
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);
|
|
|
|
// The server returns the created post sanitized for its author, so it can be stamped; left
|
|
// unstamped, the author's own attachments would sit behind the unverified placeholder.
|
|
const redactionVerifiedEpoch = await captureRedactionEpoch(serverUrl);
|
|
let created;
|
|
try {
|
|
created = await client.createPost({...newPost, create_at: 0});
|
|
} catch (error) {
|
|
logDebug('Error sending a post', getFullErrorMessage(error));
|
|
expireChannelDecisionsOnDenial(serverUrl, newPost.channel_id, error);
|
|
if (isServerError(error) && error.server_error_id === ServerErrors.BOR_DENIED_BY_POLICY_ERROR) {
|
|
// The stored decision allowed a burn-on-read post that the server refused, so it is out of date.
|
|
RenderPermissionsStore.expireEntry(serverUrl, newPost.channel_id);
|
|
}
|
|
|
|
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,
|
|
redactionVerifiedEpoch,
|
|
});
|
|
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 redactionVerifiedEpoch = await captureRedactionEpoch(serverUrl);
|
|
const created = await client.createPost(newPost);
|
|
const models = await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_NEW,
|
|
order: [created.id],
|
|
posts: [created],
|
|
prepareRecordsOnly: true,
|
|
redactionVerifiedEpoch,
|
|
});
|
|
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));
|
|
expireChannelDecisionsOnDenial(serverUrl, post.channelId, 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 {};
|
|
};
|
|
|
|
const hasPermalinkEmbed = (post: PostModel) => Boolean(post.metadata?.embeds?.some((embed) => embed.type === 'permalink'));
|
|
|
|
/**
|
|
* A policy or attribute change while the user was elsewhere leaves cached posts behind the epoch
|
|
* they now must satisfy. Read from the stored posts rather than remembered, so it survives a restart.
|
|
*
|
|
* Only the newest page counts, because that is all the re-verification page can re-stamp; older
|
|
* posts are revalidated one by one when they come on screen. Within it, only posts whose rendering
|
|
* depends on the decision count: a text-only or pending post is never re-stamped by anything, so
|
|
* counting it would page the channel again on every visit.
|
|
*/
|
|
const hasUnverifiedPosts = async (database: Database, channelId: string, posts: PostModel[]) => {
|
|
if (!posts.length || !(await isRedactionEnforced(database))) {
|
|
return false;
|
|
}
|
|
const requiredEpoch = await getRequiredRedactionEpoch(database, channelId);
|
|
const unverified = posts.slice(0, General.POST_CHUNK_SIZE).filter((p) => !isPostPendingOrFailed(p) && !isPostRedactionVerified(p.redactionVerifiedEpoch, requiredEpoch));
|
|
if (!unverified.length) {
|
|
return false;
|
|
}
|
|
if (unverified.some((p) => (p.metadata?.redacted_file_count ?? 0) > 0 || hasPermalinkEmbed(p))) {
|
|
return true;
|
|
}
|
|
const files = await queryFilesForPosts(database, unverified.map((p) => p.id)).fetch();
|
|
return files.length > 0;
|
|
};
|
|
|
|
/**
|
|
* Fetches posts for a channel, incrementally when the channel already has posts.
|
|
*
|
|
* GetPostsSince filters on UpdateAt, which an ABAC change never moves, so only a page fetch can
|
|
* re-verify posts we already hold. But only a since-fetch carries deletions and edits, so when both
|
|
* are needed the since-fetch still runs and a page follows it, rather than replacing it.
|
|
* @param ignoreSince re-verifies the newest page even when no stored post is behind its epoch, for
|
|
* callers that know a decision just changed (see refetchPostsForRedaction).
|
|
*/
|
|
export async function fetchPostsForChannel(serverUrl: string, channelId: string, fetchOnly = false, skipAuthors = false, groupLabel?: RequestGroupLabel, ignoreSince = false): 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;
|
|
const needsReverification = Boolean(since) && (ignoreSince || await hasUnverifiedPosts(database, channelId, postsInChannel));
|
|
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, false, data.redactionVerifiedEpoch,
|
|
);
|
|
}
|
|
}
|
|
|
|
// A since-fetch is the only path that receives deletions, and handlePosts destroys
|
|
// those posts locally. If the newest interval no longer covers any post it still
|
|
// wins postsInChannel[0] and renders a blank channel, and no since-fetch can
|
|
// repopulate it: once lastFetchedAt is past the deletion the server correctly
|
|
// returns nothing. Drop that interval and pull a page instead.
|
|
if (!fetchOnly && actionType === ActionType.POSTS.RECEIVED_SINCE) {
|
|
const remaining = await getRecentPostsInChannel(database, channelId);
|
|
if (!remaining.length) {
|
|
const intervals = await queryPostsInChannel(database, channelId).fetch();
|
|
if (intervals.length) {
|
|
logWarning('fetchPostsForChannel: newest interval has no posts left, refetching', channelId);
|
|
await database.write(() => intervals[0].destroyPermanently());
|
|
|
|
const page = await fetchPosts(serverUrl, channelId, 0, General.POST_CHUNK_SIZE, false, groupLabel);
|
|
return {...page, actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, channelId};
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stored by fetchPosts itself, which leaves lastFetchedAt alone: advancing it from a page
|
|
// would skip past deletions the since-fetch above has not seen. fetchOnly callers are
|
|
// prefetches; their posts land unverified and are re-verified when the channel is opened.
|
|
if (!fetchOnly && needsReverification) {
|
|
const {error: reverifyError} = await fetchPosts(serverUrl, channelId, 0, General.POST_CHUNK_SIZE, false, groupLabel);
|
|
if (reverifyError) {
|
|
logDebug('fetchPostsForChannel: could not re-verify the newest page', channelId, getFullErrorMessage(reverifyError));
|
|
}
|
|
}
|
|
|
|
return {posts: data.posts, order: data.order, authors, actionType, previousPostId: data.previousPostId, channelId, redactionVerifiedEpoch: data.redactionVerifiedEpoch};
|
|
} catch (error) {
|
|
logDebug('error on fetchPostsForChannel', getFullErrorMessage(error));
|
|
return {error};
|
|
} finally {
|
|
if (!fetchOnly) {
|
|
EphemeralStore.stopLoadingMessagesForChannel(serverUrl, channelId);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Re-fetches a channel's posts after an ABAC file-access decision may have changed.
|
|
*
|
|
* A since-fetch alone cannot do it: GetPostsSince filters on `UpdateAt > since`, and a
|
|
* policy or user-attribute change modifies no post row, so the affected posts are never
|
|
* returned. Only a page fetch re-runs SanitizePostListMetadataForUser over posts we already
|
|
* hold, which is what refreshes `redacted_file_count`; the since-fetch still runs first so
|
|
* deletions and edits are not skipped.
|
|
*
|
|
* Note this re-verifies the newest POST_CHUNK_SIZE posts only: older cached posts stay behind
|
|
* the required epoch, so they render neither files nor the redacted placeholder until they
|
|
* scroll into view and RedactionRevalidationManager fetches them one by one.
|
|
*/
|
|
export async function refetchPostsForRedaction(serverUrl: string, channelId: string, groupLabel?: RequestGroupLabel) {
|
|
return fetchPostsForChannel(serverUrl, channelId, false, false, groupLabel, true);
|
|
}
|
|
|
|
/**
|
|
* Pull-to-refresh for a channel. Normally a plain page fetch, but when the list is
|
|
* rendering nothing while posts are still stored, those posts are hidden rather than
|
|
* absent (e.g. a PostsInChannel interval that no longer contains any renderable post,
|
|
* which stays postsInChannel[0] and shadows the rest). A page fetch cannot recover that,
|
|
* so clear the channel's cached posts and intervals and re-fetch from scratch.
|
|
* @param isBlank whether the list is currently rendering zero rows
|
|
*/
|
|
export async function refreshPostsForChannel(serverUrl: string, channelId: string, isBlank = false) {
|
|
try {
|
|
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
|
|
if (isBlank) {
|
|
const intervals = await queryPostsInChannel(database, channelId).fetch();
|
|
if (intervals.length) {
|
|
logWarning('refreshPostsForChannel resetting hidden channel', channelId, intervals.length);
|
|
const {error: deleteError} = await deletePostsForChannel(serverUrl, channelId);
|
|
if (deleteError) {
|
|
return {error: deleteError};
|
|
}
|
|
}
|
|
}
|
|
|
|
return await fetchPostsForChannel(serverUrl, channelId);
|
|
} catch (error) {
|
|
logDebug('error on refreshPostsForChannel', getFullErrorMessage(error));
|
|
return {error};
|
|
}
|
|
}
|
|
|
|
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 redactionVerifiedEpoch = await captureRedactionEpoch(serverUrl);
|
|
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,
|
|
redactionVerifiedEpoch,
|
|
});
|
|
|
|
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, redactionVerifiedEpoch};
|
|
} 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 redactionVerifiedEpoch = await captureRedactionEpoch(serverUrl);
|
|
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,
|
|
redactionVerifiedEpoch,
|
|
});
|
|
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, redactionVerifiedEpoch};
|
|
} catch (error) {
|
|
logDebug('error on fetchPostsBefore', getFullErrorMessage(error));
|
|
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
|
|
return {error};
|
|
} finally {
|
|
if (!fetchOnly) {
|
|
EphemeralStore.stopLoadingMessagesForChannel(serverUrl, channelId);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Re-verifies the block of up to POST_CHUNK_SIZE posts older than `anchorPostId`, for posts the channel
|
|
* list shows from the database whose attachment decision fell behind the required epoch. The page runs
|
|
* the same sanitization as a channel fetch, so one request settles the whole block instead of one
|
|
* GET /posts/{id} per post.
|
|
*
|
|
* Unlike fetchPostsBefore it shows no loading state, since the list is not paginating, and leaves
|
|
* PostsInChannel alone: RECEIVED_BEFORE always extends the newest interval, which a re-check anchored
|
|
* anywhere else in history must not do.
|
|
* @returns the oldest create_at received, so the caller knows how far the block reached
|
|
*/
|
|
export async function revalidatePostsBefore(serverUrl: string, channelId: string, anchorPostId: string): Promise<{oldestCreateAt?: number; error?: unknown}> {
|
|
try {
|
|
const client = NetworkManager.getClient(serverUrl);
|
|
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
const isCRTEnabled = await getIsCRTEnabled(database);
|
|
const redactionVerifiedEpoch = await captureRedactionEpoch(serverUrl);
|
|
const data = await client.getPostsBefore(channelId, anchorPostId, 0, General.POST_CHUNK_SIZE, isCRTEnabled, isCRTEnabled);
|
|
const result = processPostsFetched(data);
|
|
if (!result.posts.length) {
|
|
return {};
|
|
}
|
|
|
|
const models = await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_BEFORE,
|
|
...result,
|
|
prepareRecordsOnly: true,
|
|
skipPostsInChannel: true,
|
|
redactionVerifiedEpoch,
|
|
});
|
|
const {authors} = await fetchPostAuthors(serverUrl, result.posts, true);
|
|
if (authors?.length) {
|
|
models.push(...await operator.handleUsers({users: authors, prepareRecordsOnly: true}));
|
|
}
|
|
await operator.batchRecords(models, 'revalidatePostsBefore');
|
|
|
|
return {oldestCreateAt: Math.min(...result.posts.map((p) => p.create_at))};
|
|
} catch (error) {
|
|
logDebug('error on revalidatePostsBefore', getFullErrorMessage(error));
|
|
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
|
|
return {error};
|
|
}
|
|
}
|
|
|
|
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 redactionVerifiedEpoch = await captureRedactionEpoch(serverUrl);
|
|
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,
|
|
redactionVerifiedEpoch,
|
|
});
|
|
|
|
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, redactionVerifiedEpoch};
|
|
} 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);
|
|
const redactionVerifiedEpoch = await captureRedactionEpoch(serverUrl);
|
|
|
|
// 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,
|
|
redactionVerifiedEpoch,
|
|
});
|
|
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, redactionVerifiedEpoch};
|
|
} 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 redactionVerifiedEpoch = await captureRedactionEpoch(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,
|
|
redactionVerifiedEpoch,
|
|
});
|
|
|
|
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, redactionVerifiedEpoch};
|
|
} 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};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetches a single post, sanitized for the current user, and stores it unless fetchOnly.
|
|
* @param skipPostsInChannel store the post without recording it as its channel's newest; for posts
|
|
* pulled on their own (a permalink's linked post, a revalidation) whose neighbours we do not hold
|
|
*/
|
|
export async function fetchPostById(serverUrl: string, postId: string, fetchOnly = false, groupLabel?: RequestGroupLabel, skipPostsInChannel = false) {
|
|
try {
|
|
const client = NetworkManager.getClient(serverUrl);
|
|
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
|
|
const redactionVerifiedEpoch = await captureRedactionEpoch(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,
|
|
skipPostsInChannel,
|
|
redactionVerifiedEpoch,
|
|
});
|
|
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, redactionVerifiedEpoch};
|
|
} catch (error) {
|
|
logDebug('error on fetchPostById', getFullErrorMessage(error));
|
|
forceLogoutIfNecessary(serverUrl, error);
|
|
return {error};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetches the post behind a permalink preview.
|
|
*
|
|
* Unlike a plain fetchPostById this does not touch PostsInChannel: the post is pulled on its own,
|
|
* so recording it as the newest post of its channel would either create a lone interval for a
|
|
* channel the user has never opened, or widen an existing interval over posts we do not hold.
|
|
*/
|
|
export async function fetchLinkedPost(serverUrl: string, postId: string, groupLabel?: RequestGroupLabel) {
|
|
return fetchPostById(serverUrl, postId, false, groupLabel, true);
|
|
}
|
|
|
|
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 mentionsCount = channelMember.mention_count;
|
|
const urgentMentionCount = channelMember.urgent_mention_count ?? 0;
|
|
|
|
if (isCRTEnabled) {
|
|
totalMessages = channel.total_msg_count_root!;
|
|
messages = channelMember.msg_count_root!;
|
|
mentionsCount = channelMember.mention_count_root!;
|
|
}
|
|
|
|
const messageCount = totalMessages - messages;
|
|
await markChannelAsUnread(serverUrl, {
|
|
channelId,
|
|
messageCount,
|
|
mentionsCount,
|
|
urgentMentionCount,
|
|
lastViewed: 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 redactionVerifiedEpoch = await captureRedactionEpoch(serverUrl);
|
|
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,
|
|
redactionVerifiedEpoch,
|
|
}),
|
|
);
|
|
|
|
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 redactionVerifiedEpoch = await captureRedactionEpoch(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,
|
|
redactionVerifiedEpoch,
|
|
}),
|
|
);
|
|
|
|
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);
|
|
}
|
|
}
|