[MM-48375 Gekidou] Threads Sync Fix (#6788)

* Init

* Test fix

* New sync implementation

* misc

* Includes migration and other servers sync

* Misc

* Migration fix

* Migration is done version 7

* Update app/queries/servers/thread.ts

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

* Update app/database/operator/server_data_operator/handlers/team_threads_sync.ts

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

* Feedback changes

* Fixes when old thread gets a reply

* Fix

Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Anurag Shivarathri
2022-12-01 21:38:27 +05:30
committed by GitHub
parent f3f5cef8d1
commit 0e5d63a7c3
34 changed files with 631 additions and 336 deletions

View File

@@ -168,7 +168,7 @@ export async function createThreadFromNewPost(serverUrl: string, post: Post, pre
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const models: Model[] = [];
if (post.root_id) {
// Update the thread data: `reply_count`
// Update the thread data: `reply_count`
const {model: threadModel} = await updateThread(serverUrl, post.root_id, {reply_count: post.reply_count}, true);
if (threadModel) {
models.push(threadModel);
@@ -204,7 +204,7 @@ export async function createThreadFromNewPost(serverUrl: string, post: Post, pre
}
// On receiving threads, Along with the "threads" & "thread participants", extract and save "posts" & "users"
export async function processReceivedThreads(serverUrl: string, threads: Thread[], teamId: string, loadedInGlobalThreads = false, prepareRecordsOnly = false) {
export async function processReceivedThreads(serverUrl: string, threads: Thread[], teamId: string, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const currentUserId = await getCurrentUserId(database);
@@ -236,7 +236,6 @@ export async function processReceivedThreads(serverUrl: string, threads: Thread[
threads: threadsToHandle,
teamId,
prepareRecordsOnly: true,
loadedInGlobalThreads,
});
const models = [...postModels, ...threadModels];
@@ -328,3 +327,17 @@ export async function updateThread(serverUrl: string, threadId: string, updatedT
return {error};
}
}
export async function updateTeamThreadsSync(serverUrl: string, data: TeamThreadsSync, prepareRecordsOnly = false) {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const models = await operator.handleTeamThreadsSync({data: [data], prepareRecordsOnly});
if (!prepareRecordsOnly) {
await operator.batchRecords(models);
}
return {models};
} catch (error) {
logError('Failed updateTeamThreadsSync', error);
return {error};
}
}

View File

@@ -10,7 +10,7 @@ import {MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preferen
import {fetchRoles} from '@actions/remote/role';
import {fetchConfigAndLicense} from '@actions/remote/systems';
import {fetchAllTeams, fetchMyTeams, fetchTeamsChannelsAndUnreadPosts, MyTeamsRequest} from '@actions/remote/team';
import {fetchNewThreads} from '@actions/remote/thread';
import {syncTeamThreads} from '@actions/remote/thread';
import {autoUpdateTimezone, fetchMe, MyUserRequest, updateAllUsersSince} from '@actions/remote/user';
import {gqlAllChannels} from '@client/graphQL/entry';
import {General, Preferences, Screens} from '@constants';
@@ -28,6 +28,7 @@ import {prepareModels, truncateCrtRelatedTables} from '@queries/servers/entry';
import {getHasCRTChanged} from '@queries/servers/preference';
import {getConfig, getCurrentUserId, getPushVerificationStatus, getWebSocketLastDisconnected} from '@queries/servers/system';
import {deleteMyTeams, getAvailableTeamIds, getTeamChannelHistory, queryMyTeams, queryMyTeamsByIds, queryTeamsById} from '@queries/servers/team';
import {getIsCRTEnabled} from '@queries/servers/thread';
import {isDMorGM, sortChannelsByDisplayName} from '@utils/channel';
import {getMemberChannelsFromGQLQuery, gqlToClientChannelMembership} from '@utils/graphql';
import {logDebug} from '@utils/log';
@@ -327,14 +328,14 @@ export async function restDeferredAppEntryActions(
if (preferences && processIsCRTEnabled(preferences, config.CollapsedThreads, config.FeatureFlagCollapsedThreads)) {
if (initialTeamId) {
await fetchNewThreads(serverUrl, initialTeamId, false);
await syncTeamThreads(serverUrl, initialTeamId);
}
if (teamData.teams?.length) {
for await (const team of teamData.teams) {
if (team.id !== initialTeamId) {
// need to await here since GM/DM threads in different teams overlap
await fetchNewThreads(serverUrl, team.id, false);
await syncTeamThreads(serverUrl, team.id);
}
}
}
@@ -406,6 +407,8 @@ const graphQLSyncAllChannelMembers = async (serverUrl: string) => {
return 'Server database not found';
}
const {database} = operator;
const response = await gqlAllChannels(serverUrl);
if ('error' in response) {
return response.error;
@@ -415,7 +418,7 @@ const graphQLSyncAllChannelMembers = async (serverUrl: string) => {
return response.errors[0].message;
}
const userId = await getCurrentUserId(operator.database);
const userId = await getCurrentUserId(database);
const channels = getMemberChannelsFromGQLQuery(response.data);
const memberships = response.data.channelMembers?.map((m) => gqlToClientChannelMembership(m, userId));
@@ -424,7 +427,16 @@ const graphQLSyncAllChannelMembers = async (serverUrl: string) => {
const modelPromises = await prepareMyChannelsForTeam(operator, '', channels, memberships, undefined, true);
const models = (await Promise.all(modelPromises)).flat();
if (models.length) {
operator.batchRecords(models);
await operator.batchRecords(models);
}
}
const isCRTEnabled = await getIsCRTEnabled(database);
if (isCRTEnabled) {
const myTeams = await queryMyTeams(operator.database).fetch();
for await (const myTeam of myTeams) {
// need to await here since GM/DM threads in different teams overlap
await syncTeamThreads(serverUrl, myTeam.id);
}
}
@@ -445,11 +457,12 @@ const restSyncAllChannelMembers = async (serverUrl: string) => {
const config = await client.getClientConfigOld();
let excludeDirect = false;
for (const myTeam of myTeams) {
for await (const myTeam of myTeams) {
fetchMyChannelsForTeam(serverUrl, myTeam.id, false, 0, false, excludeDirect);
excludeDirect = true;
if (preferences && processIsCRTEnabled(preferences, config.CollapsedThreads, config.FeatureFlagCollapsedThreads)) {
fetchNewThreads(serverUrl, myTeam.id, false);
// need to await here since GM/DM threads in different teams overlap
await syncTeamThreads(serverUrl, myTeam.id);
}
}
} catch {

View File

@@ -8,7 +8,7 @@ import {MyChannelsRequest} from '@actions/remote/channel';
import {fetchGroupsForMember} from '@actions/remote/groups';
import {fetchPostsForUnreadChannels} from '@actions/remote/post';
import {MyTeamsRequest} from '@actions/remote/team';
import {fetchNewThreads} from '@actions/remote/thread';
import {syncTeamThreads} from '@actions/remote/thread';
import {autoUpdateTimezone, updateAllUsersSince} from '@actions/remote/user';
import {gqlEntry, gqlEntryChannels, gqlOtherChannels} from '@client/graphQL/entry';
import {Preferences} from '@constants';
@@ -54,14 +54,14 @@ export async function deferredAppEntryGraphQLActions(
if (preferences && processIsCRTEnabled(preferences, config.CollapsedThreads, config.FeatureFlagCollapsedThreads)) {
if (initialTeamId) {
await fetchNewThreads(serverUrl, initialTeamId, false);
await syncTeamThreads(serverUrl, initialTeamId);
}
if (teamData.teams?.length) {
for await (const team of teamData.teams) {
if (team.id !== initialTeamId) {
// need to await here since GM/DM threads in different teams overlap
await fetchNewThreads(serverUrl, team.id, false);
await syncTeamThreads(serverUrl, team.id);
}
}
}

View File

@@ -791,7 +791,7 @@ export async function fetchPostById(serverUrl: string, postId: string, fetchOnly
if (authors?.length) {
const users = await operator.handleUsers({
users: authors,
prepareRecordsOnly: false,
prepareRecordsOnly: true,
});
models.push(...users);
}

View File

@@ -1,7 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {markTeamThreadsAsRead, markThreadAsViewed, processReceivedThreads, switchToThread, updateThread} from '@actions/local/thread';
import Model from '@nozbe/watermelondb/Model';
import {markTeamThreadsAsRead, markThreadAsViewed, processReceivedThreads, switchToThread, updateTeamThreadsSync, updateThread} from '@actions/local/thread';
import {fetchPostThread} from '@actions/remote/post';
import {General} from '@constants';
import DatabaseManager from '@database/manager';
@@ -10,19 +12,13 @@ import AppsManager from '@managers/apps_manager';
import NetworkManager from '@managers/network_manager';
import {getPostById} from '@queries/servers/post';
import {getConfigValue, getCurrentChannelId, getCurrentTeamId} from '@queries/servers/system';
import {getIsCRTEnabled, getNewestThreadInTeam, getThreadById} from '@queries/servers/thread';
import {getIsCRTEnabled, getThreadById, getTeamThreadsSyncData} from '@queries/servers/thread';
import {getCurrentUser} from '@queries/servers/user';
import {getThreadsListEdges} from '@utils/thread';
import {forceLogoutIfNecessary} from './session';
import type {Client} from '@client/rest';
import type {Model} from '@nozbe/watermelondb';
type FetchThreadsRequest = {
error?: unknown;
} | {
data: GetUserThreadsResponse;
};
type FetchThreadsOptions = {
before?: string;
@@ -34,6 +30,11 @@ type FetchThreadsOptions = {
totalsOnly?: boolean;
};
enum Direction {
Up,
Down,
}
export const fetchAndSwitchToThread = async (serverUrl: string, rootId: string, isFromNotification = false) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
@@ -74,57 +75,6 @@ export const fetchAndSwitchToThread = async (serverUrl: string, rootId: string,
return {};
};
export const fetchThreads = async (
serverUrl: string,
teamId: string,
{
before,
after,
perPage = General.CRT_CHUNK_SIZE,
deleted = false,
unread = false,
since,
}: FetchThreadsOptions = {
perPage: General.CRT_CHUNK_SIZE,
deleted: false,
unread: false,
since: 0,
},
): Promise<FetchThreadsRequest> => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const version = await getConfigValue(database, 'Version');
const data = await client.getThreads('me', teamId, before, after, perPage, deleted, unread, since, false, version);
const {threads} = data;
if (threads.length) {
// Mark all fetched threads as following
threads.forEach((thread: Thread) => {
thread.is_following = true;
});
await processReceivedThreads(serverUrl, threads, teamId, !unread, false);
}
return {data};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const fetchThread = async (serverUrl: string, teamId: string, threadId: string, extended?: boolean) => {
let client;
try {
@@ -136,7 +86,7 @@ export const fetchThread = async (serverUrl: string, teamId: string, threadId: s
try {
const thread = await client.getThread('me', teamId, threadId, extended);
await processReceivedThreads(serverUrl, [thread], teamId, false, false);
await processReceivedThreads(serverUrl, [thread], teamId);
return {data: thread};
} catch (error) {
@@ -286,17 +236,13 @@ export const updateThreadFollowing = async (serverUrl: string, teamId: string, t
}
};
enum Direction {
Up,
Down,
}
async function fetchBatchThreads(
export const fetchThreads = async (
serverUrl: string,
teamId: string,
options: FetchThreadsOptions,
direction?: Direction,
pages?: number,
): Promise<{error: unknown; data?: Thread[]}> {
) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
@@ -310,12 +256,7 @@ async function fetchBatchThreads(
return {error};
}
// if we start from the begging of time (since = 0) we need to fetch threads from newest to oldest (Direction.Down)
// if there is another point in time, we need to fetch threads from oldest to newest (Direction.Up)
let direction = Direction.Up;
if (options.since === 0) {
direction = Direction.Down;
}
const fetchDirection = direction ?? Direction.Up;
const currentUser = await getCurrentUser(operator.database);
if (!currentUser) {
@@ -323,34 +264,32 @@ async function fetchBatchThreads(
}
const version = await getConfigValue(operator.database, 'Version');
const data: Thread[] = [];
const threadsData: Thread[] = [];
let currentPage = 0;
const fetchThreadsFunc = async (opts: FetchThreadsOptions) => {
let page = 0;
const {before, after, perPage = General.CRT_CHUNK_SIZE, deleted, unread, since} = opts;
page += 1;
currentPage++;
const {threads} = await client.getThreads(currentUser.id, teamId, before, after, perPage, deleted, unread, since, false, version);
if (threads.length) {
// Mark all fetched threads as following
for (const thread of threads) {
thread.is_following = true;
thread.is_following = thread.is_following ?? true;
}
data.push(...threads);
threadsData.push(...threads);
if (threads.length === perPage) {
if (threads.length === perPage && (pages == null || currentPage < pages!)) {
const newOptions: FetchThreadsOptions = {perPage, deleted, unread};
if (direction === Direction.Down) {
if (fetchDirection === Direction.Down) {
const last = threads[threads.length - 1];
newOptions.before = last.id;
} else {
const first = threads[0];
newOptions.after = first.id;
}
if (pages != null && page < pages) {
fetchThreadsFunc(newOptions);
}
await fetchThreadsFunc(newOptions);
}
}
};
@@ -361,140 +300,179 @@ async function fetchBatchThreads(
if (__DEV__) {
throw error;
}
return {error, data};
return {error};
}
return {error: false, data};
}
export async function fetchNewThreads(
serverUrl: string,
teamId: string,
prepareRecordsOnly = false,
): Promise<{error: unknown; models?: Model[]}> {
const options: FetchThreadsOptions = {
unread: false,
deleted: true,
perPage: 60,
};
return {error: false, threads: threadsData};
};
export const syncTeamThreads = async (serverUrl: string, teamId: string, prepareRecordsOnly = false) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const newestThread = await getNewestThreadInTeam(operator.database, teamId, false);
options.since = newestThread ? newestThread.lastReplyAt : 0;
try {
const syncData = await getTeamThreadsSyncData(operator.database, teamId);
const syncDataUpdate = {
id: teamId,
} as TeamThreadsSync;
let response: {
error: unknown;
data?: Thread[];
} = {
error: undefined,
data: [],
};
const threads: Thread[] = [];
let loadedInGlobalThreads = true;
// if we have no threads in the DB fetch all unread ones
if (options.since === 0) {
// options to fetch all unread threads
options.deleted = false;
options.unread = true;
loadedInGlobalThreads = false;
}
response = await fetchBatchThreads(serverUrl, teamId, options);
const {error: nErr, data} = response;
if (nErr) {
return {error: nErr};
}
if (!data?.length) {
return {error: false, models: []};
}
const {error, models} = await processReceivedThreads(serverUrl, data, teamId, loadedInGlobalThreads, true);
if (!error && !prepareRecordsOnly && models?.length) {
try {
await operator.batchRecords(models);
} catch (err) {
if (__DEV__) {
throw err;
/**
* If Syncing for the first time,
* - Get all unread threads to show the right badges
* - Get latest threads to show by default in the global threads screen
* Else
* - Get all threads since last sync
*/
if (!syncData || !syncData?.latest) {
const [allUnreadThreads, latestThreads] = await Promise.all([
fetchThreads(
serverUrl,
teamId,
{unread: true},
Direction.Down,
),
fetchThreads(
serverUrl,
teamId,
{},
undefined,
1,
),
]);
if (allUnreadThreads.error || latestThreads.error) {
return {error: allUnreadThreads.error || latestThreads.error};
}
if (latestThreads.threads?.length) {
// We are fetching the threads for the first time. We get "latest" and "earliest" values.
const {earliestThread, latestThread} = getThreadsListEdges(latestThreads.threads);
syncDataUpdate.latest = latestThread.last_reply_at;
syncDataUpdate.earliest = earliestThread.last_reply_at;
threads.push(...latestThreads.threads);
}
if (allUnreadThreads.threads?.length) {
threads.push(...allUnreadThreads.threads);
}
} else {
const allNewThreads = await fetchThreads(
serverUrl,
teamId,
{deleted: true, since: syncData.latest},
);
if (allNewThreads.error) {
return {error: allNewThreads.error};
}
if (allNewThreads.threads?.length) {
// As we are syncing, we get all new threads and we will update the "latest" value.
const {latestThread} = getThreadsListEdges(allNewThreads.threads);
syncDataUpdate.latest = latestThread.last_reply_at;
threads.push(...allNewThreads.threads);
}
return {error: true};
}
const models: Model[] = [];
if (threads.length) {
const {error, models: threadModels = []} = await processReceivedThreads(serverUrl, threads, teamId, true);
if (error) {
return {error};
}
if (threadModels?.length) {
models.push(...threadModels);
}
if (syncDataUpdate.earliest || syncDataUpdate.latest) {
const {models: updateModels} = await updateTeamThreadsSync(serverUrl, syncDataUpdate, true);
if (updateModels?.length) {
models.push(...updateModels);
}
}
if (!prepareRecordsOnly && models?.length) {
try {
await operator.batchRecords(models);
} catch (err) {
if (__DEV__) {
throw err;
}
return {error: err};
}
}
}
return {error: false, models};
} catch (error) {
return {error};
}
};
return {error: false, models};
}
export async function fetchRefreshThreads(
serverUrl: string,
teamId: string,
unread = false,
prepareRecordsOnly = false,
): Promise<{error: unknown; models?: Model[]}> {
const options: FetchThreadsOptions = {
unread,
deleted: true,
perPage: 60,
};
export const loadEarlierThreads = async (serverUrl: string, teamId: string, lastThreadId: string, prepareRecordsOnly = false) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const newestThread = await getNewestThreadInTeam(operator.database, teamId, unread);
options.since = newestThread ? newestThread.lastReplyAt : 0;
let response: {
error: unknown;
data?: Thread[];
} = {
error: undefined,
data: [],
};
let pages;
// in the case of global threads: if we have no threads in the DB fetch just one page
if (options.since === 0 && !unread) {
pages = 1;
}
response = await fetchBatchThreads(serverUrl, teamId, options, pages);
const {error: nErr, data} = response;
if (nErr) {
return {error: nErr};
}
if (!data?.length) {
return {error: false, models: []};
}
const loadedInGlobalThreads = !unread;
const {error, models} = await processReceivedThreads(serverUrl, data, teamId, loadedInGlobalThreads, true);
if (!error && !prepareRecordsOnly && models?.length) {
try {
await operator.batchRecords(models);
} catch (err) {
if (__DEV__) {
throw err;
}
return {error: true};
try {
/*
* - We will fetch one page of old threads
* - Update the sync data with the earliest thread last_reply_at timestamp
*/
const fetchedThreads = await fetchThreads(
serverUrl,
teamId,
{
before: lastThreadId,
},
undefined,
1,
);
if (fetchedThreads.error) {
return {error: fetchedThreads.error};
}
}
return {error: false, models};
}
const models: Model[] = [];
const threads = fetchedThreads.threads || [];
if (threads?.length) {
const {error, models: threadModels = []} = await processReceivedThreads(serverUrl, threads, teamId, true);
if (error) {
return {error};
}
if (threadModels?.length) {
models.push(...threadModels);
}
const {earliestThread} = getThreadsListEdges(threads);
const syncDataUpdate = {
id: teamId,
earliest: earliestThread.last_reply_at,
} as TeamThreadsSync;
const {models: updateModels} = await updateTeamThreadsSync(serverUrl, syncDataUpdate, true);
if (updateModels?.length) {
models.push(...updateModels);
}
if (!prepareRecordsOnly && models?.length) {
try {
await operator.batchRecords(models);
} catch (err) {
if (__DEV__) {
throw err;
}
return {error: err};
}
}
}
return {error: false, models, threads};
} catch (error) {
return {error};
}
};

View File

@@ -40,6 +40,7 @@ export const MM_TABLES = {
THREAD: 'Thread',
THREADS_IN_TEAM: 'ThreadsInTeam',
THREAD_PARTICIPANT: 'ThreadParticipant',
TEAM_THREADS_SYNC: 'TeamThreadsSync',
USER: 'User',
},
};

View File

@@ -15,7 +15,7 @@ import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, Cha
GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel,
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel,
SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
ThreadModel, ThreadParticipantModel, ThreadInTeamModel, UserModel,
ThreadModel, ThreadParticipantModel, ThreadInTeamModel, TeamThreadsSyncModel, UserModel,
} from '@database/models/server';
import AppDataOperator from '@database/operator/app_data_operator';
import ServerDataOperator from '@database/operator/server_data_operator';
@@ -51,7 +51,7 @@ class DatabaseManager {
GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel,
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel,
SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
ThreadModel, ThreadParticipantModel, ThreadInTeamModel, UserModel,
ThreadModel, ThreadParticipantModel, ThreadInTeamModel, TeamThreadsSyncModel, UserModel,
];
this.databaseDirectory = '';
}

View File

@@ -16,7 +16,7 @@ import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, Cha
GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel,
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel,
SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
ThreadModel, ThreadParticipantModel, ThreadInTeamModel, UserModel, ConfigModel,
ThreadModel, ThreadParticipantModel, ThreadInTeamModel, TeamThreadsSyncModel, UserModel, ConfigModel,
} from '@database/models/server';
import AppDataOperator from '@database/operator/app_data_operator';
import ServerDataOperator from '@database/operator/server_data_operator';
@@ -50,7 +50,7 @@ class DatabaseManager {
GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel,
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel,
SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
ThreadModel, ThreadParticipantModel, ThreadInTeamModel, UserModel,
ThreadModel, ThreadParticipantModel, ThreadInTeamModel, TeamThreadsSyncModel, UserModel,
];
this.databaseDirectory = Platform.OS === 'ios' ? getIOSAppGroupDetails().appGroupDatabase : `${FileSystem.DocumentDirectoryPath}/databases/`;

View File

@@ -8,16 +8,46 @@ import {schemaMigrations, addColumns, createTable} from '@nozbe/watermelondb/Sch
import {MM_TABLES} from '@constants/database';
import {tableSchemaSpec as configSpec} from '@database/schema/server/table_schemas/config';
import {tableSchemaSpec as teamThreadsSyncSpec} from '@database/schema/server/table_schemas/team_threads_sync';
import {tableSchemaSpec as threadSpec} from '@database/schema/server/table_schemas/thread';
import {tableSchemaSpec as threadInTeamSpec} from '@database/schema/server/table_schemas/thread_in_team';
import {tableSchemaSpec as threadParticipantSpec} from '@database/schema/server/table_schemas/thread_participant';
const {SERVER: {
GROUP,
MY_CHANNEL,
TEAM,
THREAD,
THREAD_PARTICIPANT,
THREADS_IN_TEAM,
USER,
}} = MM_TABLES;
export default schemaMigrations({migrations: [
{
toVersion: 7,
steps: [
// Along with adding the new table - TeamThreadsSync,
// We need to clear the data in thread related tables (DROP & CREATE) to fetch the fresh data from the server
createTable({
...teamThreadsSyncSpec,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
unsafeSql: (baseSql) => {
return `
${baseSql}
DROP TABLE ${THREAD};
DROP TABLE ${THREADS_IN_TEAM};
DROP TABLE ${THREAD_PARTICIPANT};
`;
},
}),
createTable(threadSpec),
createTable(threadInTeamSpec),
createTable(threadParticipantSpec),
],
},
{
toVersion: 6,
steps: [

View File

@@ -31,4 +31,5 @@ export {default as TeamSearchHistoryModel} from './team_search_history';
export {default as ThreadModel} from './thread';
export {default as ThreadInTeamModel} from './thread_in_team';
export {default as ThreadParticipantModel} from './thread_participant';
export {default as TeamThreadsSyncModel} from './team_threads_sync';
export {default as UserModel} from './user';

View File

@@ -110,10 +110,7 @@ export default class TeamModel extends Model implements TeamModelInterface {
/** threads : Threads list belonging to a team */
@lazy threadsList = this.collections.get<ThreadModel>(THREAD).query(
Q.on(THREADS_IN_TEAM, Q.and(
Q.where('team_id', this.id),
Q.where('loaded_in_global_threads', true),
)),
Q.on(THREADS_IN_TEAM, 'team_id', this.id),
Q.and(
Q.where('reply_count', Q.gt(0)),
Q.where('is_following', true),

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Relation} from '@nozbe/watermelondb';
import {field, immutableRelation} from '@nozbe/watermelondb/decorators';
import Model, {Associations} from '@nozbe/watermelondb/Model';
import {MM_TABLES} from '@constants/database';
import type TeamModel from '@typings/database/models/servers/team';
import type TeamThreadsSyncModelInterface from '@typings/database/models/servers/team_threads_sync';
const {TEAM, TEAM_THREADS_SYNC} = MM_TABLES.SERVER;
/**
* ThreadInTeam model helps us to sync threads without creating any gaps between the threads
* by keeping track of the latest and earliest last_replied_at timestamps loaded for a team.
*/
export default class TeamThreadsSyncModel extends Model implements TeamThreadsSyncModelInterface {
/** table (name) : TeamThreadsSync */
static table = TEAM_THREADS_SYNC;
/** associations : Describes every relationship to this table. */
static associations: Associations = {
[TEAM]: {type: 'belongs_to', key: 'id'},
};
/** earliest: Oldest last_replied_at loaded through infinite loading */
@field('earliest') earliest!: number;
/** latest: Newest last_replied_at loaded during app init / navigating to global threads / pull to refresh */
@field('latest') latest!: number;
@immutableRelation(TEAM, 'id') team!: Relation<TeamModel>;
}

View File

@@ -37,9 +37,6 @@ export default class ThreadInTeamModel extends Model implements ThreadInTeamMode
/** team_id: Associated team identifier */
@field('team_id') teamId!: string;
/** loaded_in_global_threads : Flag to differentiate the unread threads loaded for showing unread counts/mentions */
@field('loaded_in_global_threads') loadedInGlobalThreads!: boolean;
@immutableRelation(THREAD, 'thread_id') thread!: Relation<ThreadModel>;
@immutableRelation(TEAM, 'team_id') team!: Relation<TeamModel>;

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q, Database} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database';
import {transformTeamThreadsSyncRecord} from '@database/operator/server_data_operator/transformers/thread';
import {getRawRecordPairs, getUniqueRawsBy, getValidRecordsForUpdate} from '@database/operator/utils/general';
import {logWarning} from '@utils/log';
import type {HandleTeamThreadsSyncArgs, RecordPair} from '@typings/database/database';
import type TeamThreadsSyncModel from '@typings/database/models/servers/team_threads_sync';
export interface TeamThreadsSyncHandlerMix {
handleTeamThreadsSync: ({data, prepareRecordsOnly}: HandleTeamThreadsSyncArgs) => Promise<TeamThreadsSyncModel[]>;
}
const {TEAM_THREADS_SYNC} = MM_TABLES.SERVER;
const TeamThreadsSyncHandler = (superclass: any) => class extends superclass {
handleTeamThreadsSync = async ({data, prepareRecordsOnly = false}: HandleTeamThreadsSyncArgs): Promise<TeamThreadsSyncModel[]> => {
if (!data || !data.length) {
logWarning(
'An empty or undefined "data" array has been passed to the handleTeamThreadsSync method',
);
return [];
}
const uniqueRaws = getUniqueRawsBy({raws: data, key: 'id'}) as TeamThreadsSync[];
const ids = uniqueRaws.map((item) => item.id);
const chunks = await (this.database as Database).get<TeamThreadsSyncModel>(TEAM_THREADS_SYNC).query(
Q.where('id', Q.oneOf(ids)),
).fetch();
const chunksMap = chunks.reduce((result: Record<string, TeamThreadsSyncModel>, chunk) => {
result[chunk.id] = chunk;
return result;
}, {});
const create: TeamThreadsSync[] = [];
const update: RecordPair[] = [];
for await (const item of uniqueRaws) {
const {id} = item;
const chunk = chunksMap[id];
if (chunk) {
update.push(getValidRecordsForUpdate({
tableName: TEAM_THREADS_SYNC,
newValue: item,
existingRecord: chunk,
}));
} else {
create.push(item);
}
}
const models = (await this.prepareRecords({
createRaws: getRawRecordPairs(create),
updateRaws: update,
transformer: transformTeamThreadsSyncRecord,
tableName: TEAM_THREADS_SYNC,
})) as TeamThreadsSyncModel[];
if (models?.length && !prepareRecordsOnly) {
await this.batchRecords(models);
}
return models;
};
};
export default TeamThreadsSyncHandler;

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import {transformThreadRecord, transformThreadParticipantRecord, transformThreadInTeamRecord} from '@database/operator/server_data_operator/transformers/thread';
import {transformThreadRecord, transformThreadParticipantRecord, transformThreadInTeamRecord, transformTeamThreadsSyncRecord} from '@database/operator/server_data_operator/transformers/thread';
import ServerDataOperator from '..';
@@ -51,7 +51,7 @@ describe('*** Operator: Thread Handlers tests ***', () => {
] as ThreadWithLastFetchedAt[];
const threadsMap = {team_id_1: threads};
await operator.handleThreads({threads, loadedInGlobalThreads: false, prepareRecordsOnly: false, teamId: 'team_id_1'});
await operator.handleThreads({threads, prepareRecordsOnly: false, teamId: 'team_id_1'});
expect(spyOnHandleRecords).toHaveBeenCalledWith({
fieldName: 'id',
@@ -76,7 +76,6 @@ describe('*** Operator: Thread Handlers tests ***', () => {
expect(spyOnHandleThreadInTeam).toHaveBeenCalledWith({
threadsMap,
prepareRecordsOnly: true,
loadedInGlobalThreads: false,
});
// Only one batch operation for both tables
@@ -161,21 +160,77 @@ describe('*** Operator: Thread Handlers tests ***', () => {
team_id_2: team2Threads,
};
await operator.handleThreadInTeam({threadsMap, loadedInGlobalThreads: true, prepareRecordsOnly: false});
await operator.handleThreadInTeam({threadsMap, prepareRecordsOnly: false});
expect(spyOnPrepareRecords).toHaveBeenCalledWith({
createRaws: [{
raw: {team_id: 'team_id_1', thread_id: 'thread-2', loaded_in_global_threads: true},
raw: {team_id: 'team_id_1', thread_id: 'thread-1'},
}, {
raw: {team_id: 'team_id_2', thread_id: 'thread-2', loaded_in_global_threads: true},
raw: {team_id: 'team_id_1', thread_id: 'thread-2'},
}, {
raw: {team_id: 'team_id_2', thread_id: 'thread-2'},
}],
transformer: transformThreadInTeamRecord,
updateRaws: [
expect.objectContaining({
raw: {team_id: 'team_id_1', thread_id: 'thread-1', loaded_in_global_threads: true},
}),
],
tableName: 'ThreadsInTeam',
});
});
it('=> HandleTeamThreadsSync: should write to the the TeamThreadsSync table', async () => {
expect.assertions(1);
const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords');
const data = [
{
id: 'team_id_1',
earliest: 100,
latest: 200,
},
{
id: 'team_id_2',
earliest: 100,
latest: 300,
},
] as TeamThreadsSync[];
await operator.handleTeamThreadsSync({data, prepareRecordsOnly: false});
expect(spyOnPrepareRecords).toHaveBeenCalledWith({
createRaws: [{
raw: {id: 'team_id_1', earliest: 100, latest: 200},
}, {
raw: {id: 'team_id_2', earliest: 100, latest: 300},
}],
updateRaws: [],
transformer: transformTeamThreadsSyncRecord,
tableName: 'TeamThreadsSync',
});
});
it('=> HandleTeamThreadsSync: should update the record in TeamThreadsSync table', async () => {
expect.assertions(1);
const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords');
const data = [
{
id: 'team_id_1',
earliest: 100,
latest: 300,
},
] as TeamThreadsSync[];
await operator.handleTeamThreadsSync({data, prepareRecordsOnly: false});
expect(spyOnPrepareRecords).toHaveBeenCalledWith({
createRaws: [],
updateRaws: [
expect.objectContaining({
raw: {id: 'team_id_1', earliest: 100, latest: 300},
}),
],
transformer: transformTeamThreadsSyncRecord,
tableName: 'TeamThreadsSync',
});
});
});

View File

@@ -25,7 +25,7 @@ const {
} = MM_TABLES.SERVER;
export interface ThreadHandlerMix {
handleThreads: ({threads, teamId, prepareRecordsOnly, loadedInGlobalThreads}: HandleThreadsArgs) => Promise<Model[]>;
handleThreads: ({threads, teamId, prepareRecordsOnly}: HandleThreadsArgs) => Promise<Model[]>;
handleThreadParticipants: ({threadsParticipants, prepareRecordsOnly}: HandleThreadParticipantsArgs) => Promise<ThreadParticipantModel[]>;
}
@@ -37,7 +37,7 @@ const ThreadHandler = (superclass: any) => class extends superclass {
* @param {boolean | undefined} handleThreads.prepareRecordsOnly
* @returns {Promise<void>}
*/
handleThreads = async ({threads, teamId, loadedInGlobalThreads, prepareRecordsOnly = false}: HandleThreadsArgs): Promise<Model[]> => {
handleThreads = async ({threads, teamId, prepareRecordsOnly = false}: HandleThreadsArgs): Promise<Model[]> => {
if (!threads?.length) {
logWarning(
'An empty or undefined "threads" array has been passed to the handleThreads method',
@@ -119,7 +119,6 @@ const ThreadHandler = (superclass: any) => class extends superclass {
const threadsInTeam = await this.handleThreadInTeam({
threadsMap: {[teamId]: threads},
prepareRecordsOnly: true,
loadedInGlobalThreads,
}) as ThreadInTeamModel[];
batch.push(...threadsInTeam);
}

View File

@@ -5,20 +5,20 @@ import {Q, Database} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database';
import {transformThreadInTeamRecord} from '@database/operator/server_data_operator/transformers/thread';
import {getRawRecordPairs, getValidRecordsForUpdate} from '@database/operator/utils/general';
import {getRawRecordPairs} from '@database/operator/utils/general';
import {logWarning} from '@utils/log';
import type {HandleThreadInTeamArgs, RecordPair} from '@typings/database/database';
import type {HandleThreadInTeamArgs} from '@typings/database/database';
import type ThreadInTeamModel from '@typings/database/models/servers/thread_in_team';
export interface ThreadInTeamHandlerMix {
handleThreadInTeam: ({threadsMap, loadedInGlobalThreads, prepareRecordsOnly}: HandleThreadInTeamArgs) => Promise<ThreadInTeamModel[]>;
handleThreadInTeam: ({threadsMap, prepareRecordsOnly}: HandleThreadInTeamArgs) => Promise<ThreadInTeamModel[]>;
}
const {THREADS_IN_TEAM} = MM_TABLES.SERVER;
const ThreadInTeamHandler = (superclass: any) => class extends superclass {
handleThreadInTeam = async ({threadsMap, loadedInGlobalThreads, prepareRecordsOnly = false}: HandleThreadInTeamArgs): Promise<ThreadInTeamModel[]> => {
handleThreadInTeam = async ({threadsMap, prepareRecordsOnly = false}: HandleThreadInTeamArgs): Promise<ThreadInTeamModel[]> => {
if (!threadsMap || !Object.keys(threadsMap).length) {
logWarning(
'An empty or undefined "threadsMap" object has been passed to the handleReceivedPostForChannel method',
@@ -26,12 +26,13 @@ const ThreadInTeamHandler = (superclass: any) => class extends superclass {
return [];
}
const update: RecordPair[] = [];
const create: ThreadInTeam[] = [];
const teamIds = Object.keys(threadsMap);
for await (const teamId of teamIds) {
const threadIds = threadsMap[teamId].map((thread) => thread.id);
const chunks = await (this.database as Database).get<ThreadInTeamModel>(THREADS_IN_TEAM).query(
Q.where('team_id', teamId),
Q.where('id', Q.oneOf(threadIds)),
).fetch();
const chunksMap = chunks.reduce((result: Record<string, ThreadInTeamModel>, chunk) => {
result[chunk.threadId] = chunk;
@@ -41,29 +42,18 @@ const ThreadInTeamHandler = (superclass: any) => class extends superclass {
for (const thread of threadsMap[teamId]) {
const chunk = chunksMap[thread.id];
const newValue = {
thread_id: thread.id,
team_id: teamId,
loaded_in_global_threads: Boolean(loadedInGlobalThreads),
};
// update record only if loaded_in_global_threads is true
if (chunk && loadedInGlobalThreads) {
update.push(getValidRecordsForUpdate({
tableName: THREADS_IN_TEAM,
newValue,
existingRecord: chunk,
}));
} else {
// create chunk
create.push(newValue);
// Create if the chunk is not found
if (!chunk) {
create.push({
thread_id: thread.id,
team_id: teamId,
});
}
}
}
const threadsInTeam = (await this.prepareRecords({
createRaws: getRawRecordPairs(create),
updateRaws: update,
transformer: transformThreadInTeamRecord,
tableName: THREADS_IN_TEAM,
})) as ThreadInTeamModel[];

View File

@@ -10,6 +10,7 @@ import PostsInChannelHandler, {PostsInChannelHandlerMix} from '@database/operato
import PostsInThreadHandler, {PostsInThreadHandlerMix} from '@database/operator/server_data_operator/handlers/posts_in_thread';
import ReactionHander, {ReactionHandlerMix} from '@database/operator/server_data_operator/handlers/reaction';
import TeamHandler, {TeamHandlerMix} from '@database/operator/server_data_operator/handlers/team';
import TeamThreadsSyncHandler, {TeamThreadsSyncHandlerMix} from '@database/operator/server_data_operator/handlers/team_threads_sync';
import ThreadHandler, {ThreadHandlerMix} from '@database/operator/server_data_operator/handlers/thread';
import ThreadInTeamHandler, {ThreadInTeamHandlerMix} from '@database/operator/server_data_operator/handlers/thread_in_team';
import UserHandler, {UserHandlerMix} from '@database/operator/server_data_operator/handlers/user';
@@ -29,6 +30,7 @@ interface ServerDataOperator extends
TeamHandlerMix,
ThreadHandlerMix,
ThreadInTeamHandlerMix,
TeamThreadsSyncHandlerMix,
UserHandlerMix
{}
@@ -43,6 +45,7 @@ class ServerDataOperator extends mix(ServerDataOperatorBase).with(
TeamHandler,
ThreadHandler,
ThreadInTeamHandler,
TeamThreadsSyncHandler,
UserHandler,
) {
// eslint-disable-next-line no-useless-constructor

View File

@@ -5,6 +5,7 @@ import {MM_TABLES, OperationType} from '@constants/database';
import {prepareBaseRecord} from '@database/operator/server_data_operator/transformers/index';
import type {TransformerArgs} from '@typings/database/database';
import type TeamThreadsSyncModel from '@typings/database/models/servers/team_threads_sync';
import type ThreadModel from '@typings/database/models/servers/thread';
import type ThreadInTeamModel from '@typings/database/models/servers/thread_in_team';
import type ThreadParticipantModel from '@typings/database/models/servers/thread_participant';
@@ -13,6 +14,7 @@ const {
THREAD,
THREAD_PARTICIPANT,
THREADS_IN_TEAM,
TEAM_THREADS_SYNC,
} = MM_TABLES.SERVER;
/**
@@ -30,7 +32,10 @@ export const transformThreadRecord = ({action, database, value}: TransformerArgs
// If isCreateAction is true, we will use the id (API response) from the RAW, else we shall use the existing record id from the database
const fieldsMapper = (thread: ThreadModel) => {
thread._raw.id = isCreateAction ? (raw?.id ?? thread.id) : record.id;
thread.lastReplyAt = raw.last_reply_at;
// When post is individually fetched, we get last_reply_at as 0, so we use the record's value
thread.lastReplyAt = raw.last_reply_at || record?.lastReplyAt;
thread.lastViewedAt = raw.last_viewed_at ?? record?.lastViewedAt ?? 0;
thread.replyCount = raw.reply_count;
thread.isFollowing = raw.is_following ?? record?.isFollowing;
@@ -76,14 +81,10 @@ export const transformThreadParticipantRecord = ({action, database, value}: Tran
export const transformThreadInTeamRecord = ({action, database, value}: TransformerArgs): Promise<ThreadInTeamModel> => {
const raw = value.raw as ThreadInTeam;
const record = value.record as ThreadInTeamModel;
const fieldsMapper = (threadInTeam: ThreadInTeamModel) => {
threadInTeam.threadId = raw.thread_id;
threadInTeam.teamId = raw.team_id;
// if it's already loaded don't change it
threadInTeam.loadedInGlobalThreads = record?.loadedInGlobalThreads || raw.loaded_in_global_threads;
};
return prepareBaseRecord({
@@ -94,3 +95,23 @@ export const transformThreadInTeamRecord = ({action, database, value}: Transform
fieldsMapper,
}) as Promise<ThreadInTeamModel>;
};
export const transformTeamThreadsSyncRecord = ({action, database, value}: TransformerArgs): Promise<TeamThreadsSyncModel> => {
const raw = value.raw as TeamThreadsSync;
const record = value.record as TeamThreadsSyncModel;
const isCreateAction = action === OperationType.CREATE;
const fieldsMapper = (teamThreadsSync: TeamThreadsSyncModel) => {
teamThreadsSync._raw.id = isCreateAction ? (raw?.id ?? teamThreadsSync.id) : record.id;
teamThreadsSync.earliest = raw.earliest || record?.earliest;
teamThreadsSync.latest = raw.latest || record?.latest;
};
return prepareBaseRecord({
action,
database,
tableName: TEAM_THREADS_SYNC,
value,
fieldsMapper,
}) as Promise<TeamThreadsSyncModel>;
};

View File

@@ -34,11 +34,12 @@ import {
ThreadSchema,
ThreadInTeamSchema,
ThreadParticipantSchema,
TeamThreadsSyncSchema,
UserSchema,
} from './table_schemas';
export const serverSchema: AppSchema = appSchema({
version: 6,
version: 7,
tables: [
CategorySchema,
CategoryChannelSchema,
@@ -67,6 +68,7 @@ export const serverSchema: AppSchema = appSchema({
TeamMembershipSchema,
TeamSchema,
TeamSearchHistorySchema,
TeamThreadsSyncSchema,
ThreadSchema,
ThreadInTeamSchema,
ThreadParticipantSchema,

View File

@@ -30,5 +30,6 @@ export {default as TeamSearchHistorySchema} from './team_search_history';
export {default as ThreadSchema} from './thread';
export {default as ThreadParticipantSchema} from './thread_participant';
export {default as ThreadInTeamSchema} from './thread_in_team';
export {default as TeamThreadsSyncSchema} from './team_threads_sync';
export {default as UserSchema} from './user';
export {default as ConfigSchema} from './config';

View File

@@ -0,0 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {tableSchema} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database';
import type {TableSchemaSpec} from '@nozbe/watermelondb/Schema';
const {TEAM_THREADS_SYNC} = MM_TABLES.SERVER;
export const tableSchemaSpec: TableSchemaSpec = {
name: TEAM_THREADS_SYNC,
columns: [
{name: 'earliest', type: 'number'},
{name: 'latest', type: 'number'},
],
};
export default tableSchema(tableSchemaSpec);

View File

@@ -5,9 +5,11 @@ import {tableSchema} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database';
import type {TableSchemaSpec} from '@nozbe/watermelondb/Schema';
const {THREAD} = MM_TABLES.SERVER;
export default tableSchema({
export const tableSchemaSpec: TableSchemaSpec = {
name: THREAD,
columns: [
{name: 'is_following', type: 'boolean'},
@@ -19,5 +21,7 @@ export default tableSchema({
{name: 'viewed_at', type: 'number'},
{name: 'last_fetched_at', type: 'number', isIndexed: true},
],
});
};
export default tableSchema(tableSchemaSpec);

View File

@@ -5,13 +5,16 @@ import {tableSchema} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database';
import type {TableSchemaSpec} from '@nozbe/watermelondb/Schema';
const {THREADS_IN_TEAM} = MM_TABLES.SERVER;
export default tableSchema({
export const tableSchemaSpec: TableSchemaSpec = {
name: THREADS_IN_TEAM,
columns: [
{name: 'loaded_in_global_threads', type: 'boolean', isIndexed: true},
{name: 'team_id', type: 'string', isIndexed: true},
{name: 'thread_id', type: 'string', isIndexed: true},
],
});
};
export default tableSchema(tableSchemaSpec);

View File

@@ -5,12 +5,16 @@ import {tableSchema} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database';
import type {TableSchemaSpec} from '@nozbe/watermelondb/Schema';
const {THREAD_PARTICIPANT} = MM_TABLES.SERVER;
export default tableSchema({
export const tableSchemaSpec: TableSchemaSpec = {
name: THREAD_PARTICIPANT,
columns: [
{name: 'thread_id', type: 'string', isIndexed: true},
{name: 'user_id', type: 'string', isIndexed: true},
],
});
};
export default tableSchema(tableSchemaSpec);

View File

@@ -38,13 +38,14 @@ const {
THREAD,
THREAD_PARTICIPANT,
THREADS_IN_TEAM,
TEAM_THREADS_SYNC,
USER,
} = MM_TABLES.SERVER;
describe('*** Test schema for SERVER database ***', () => {
it('=> The SERVER SCHEMA should strictly match', () => {
expect(serverSchema).toEqual({
version: 6,
version: 7,
unsafeSql: undefined,
tables: {
[CATEGORY]: {
@@ -570,16 +571,26 @@ describe('*** Test schema for SERVER database ***', () => {
name: THREADS_IN_TEAM,
unsafeSql: undefined,
columns: {
loaded_in_global_threads: {name: 'loaded_in_global_threads', type: 'boolean', isIndexed: true},
team_id: {name: 'team_id', type: 'string', isIndexed: true},
thread_id: {name: 'thread_id', type: 'string', isIndexed: true},
},
columnArray: [
{name: 'loaded_in_global_threads', type: 'boolean', isIndexed: true},
{name: 'team_id', type: 'string', isIndexed: true},
{name: 'thread_id', type: 'string', isIndexed: true},
],
},
[TEAM_THREADS_SYNC]: {
name: TEAM_THREADS_SYNC,
unsafeSql: undefined,
columns: {
earliest: {name: 'earliest', type: 'number'},
latest: {name: 'latest', type: 'number'},
},
columnArray: [
{name: 'earliest', type: 'number'},
{name: 'latest', type: 'number'},
],
},
[USER]: {
name: USER,
unsafeSql: undefined,

View File

@@ -14,10 +14,11 @@ import {getConfig, observeConfigValue} from './system';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type Model from '@nozbe/watermelondb/Model';
import type TeamThreadsSyncModel from '@typings/database/models/servers/team_threads_sync';
import type ThreadModel from '@typings/database/models/servers/thread';
import type UserModel from '@typings/database/models/servers/user';
const {SERVER: {CHANNEL, POST, THREAD, THREADS_IN_TEAM, THREAD_PARTICIPANT, USER}} = MM_TABLES;
const {SERVER: {CHANNEL, POST, THREAD, THREADS_IN_TEAM, THREAD_PARTICIPANT, TEAM_THREADS_SYNC, USER}} = MM_TABLES;
export const getIsCRTEnabled = async (database: Database): Promise<boolean> => {
const config = await getConfig(database);
@@ -34,6 +35,11 @@ export const getThreadById = async (database: Database, threadId: string) => {
}
};
export const getTeamThreadsSyncData = async (database: Database, teamId: string): Promise<TeamThreadsSyncModel | undefined> => {
const result = await queryTeamThreadsSync(database, teamId).fetch();
return result?.[0];
};
export const observeIsCRTEnabled = (database: Database) => {
const cfgValue = observeConfigValue(database, 'CollapsedThreads');
const featureFlag = observeConfigValue(database, 'FeatureFlagCollapsedThreads');
@@ -133,7 +139,7 @@ export const prepareThreadsFromReceivedPosts = async (operator: ServerDataOperat
return models;
};
export const queryThreadsInTeam = (database: Database, teamId: string, onlyUnreads?: boolean, hasReplies?: boolean, isFollowing?: boolean, sort?: boolean, limit?: number): Query<ThreadModel> => {
export const queryThreadsInTeam = (database: Database, teamId: string, onlyUnreads?: boolean, hasReplies?: boolean, isFollowing?: boolean, sort?: boolean, earliest?: number): Query<ThreadModel> => {
const query: Q.Clause[] = [];
if (isFollowing) {
@@ -152,38 +158,22 @@ export const queryThreadsInTeam = (database: Database, teamId: string, onlyUnrea
query.push(Q.sortBy('last_reply_at', Q.desc));
}
let joinCondition: Q.Condition = Q.where('team_id', teamId);
if (!onlyUnreads) {
joinCondition = Q.and(
Q.where('team_id', teamId),
Q.where('loaded_in_global_threads', true),
);
}
query.push(
Q.on(THREADS_IN_TEAM, joinCondition),
Q.on(THREADS_IN_TEAM, Q.where('team_id', teamId)),
);
if (limit) {
query.push(Q.take(limit));
if (earliest) {
query.push(Q.where('last_reply_at', Q.gte(earliest)));
}
return database.get<ThreadModel>(THREAD).query(...query);
};
export async function getNewestThreadInTeam(
database: Database,
teamId: string,
unread: boolean,
): Promise<ThreadModel | undefined> {
try {
const threads = await queryThreadsInTeam(database, teamId, unread, true, true, true, 1).fetch();
return threads?.[0] || undefined;
} catch (e) {
return undefined;
}
}
export const queryTeamThreadsSync = (database: Database, teamId: string) => {
return database.get<TeamThreadsSyncModel>(TEAM_THREADS_SYNC).query(
Q.where('id', teamId),
);
};
export function observeThreadMentionCount(database: Database, teamId?: string, includeDmGm?: boolean): Observable<number> {
return observeUnreadsAndMentionsInTeam(database, teamId, includeDmGm).pipe(

View File

@@ -4,9 +4,10 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {AppStateStatus} from 'react-native';
import {switchMap} from 'rxjs/operators';
import {observeCurrentTeamId} from '@queries/servers/system';
import {queryThreadsInTeam} from '@queries/servers/thread';
import {queryTeamThreadsSync, queryThreadsInTeam} from '@queries/servers/thread';
import {observeTeammateNameDisplay} from '@queries/servers/user';
import ThreadsList from './threads_list';
@@ -26,10 +27,17 @@ const withTeamId = withObservables([], ({database}: WithDatabaseArgs) => ({
const enhanced = withObservables(['tab', 'teamId', 'forceQueryAfterAppState'], ({database, tab, teamId}: Props) => {
const getOnlyUnreads = tab !== 'all';
const teamThreadsSyncObserver = queryTeamThreadsSync(database, teamId).observeWithColumns(['earliest']);
return {
unreadsCount: queryThreadsInTeam(database, teamId, true).observeCount(false),
teammateNameDisplay: observeTeammateNameDisplay(database),
threads: queryThreadsInTeam(database, teamId, getOnlyUnreads, false, true, true).observe(),
threads: teamThreadsSyncObserver.pipe(
switchMap((teamThreadsSync) => {
const earliest = teamThreadsSync?.[0]?.earliest;
return queryThreadsInTeam(database, teamId, getOnlyUnreads, false, true, true, earliest).observe();
}),
),
};
});

View File

@@ -4,7 +4,7 @@
import React, {useCallback, useEffect, useMemo, useState, useRef} from 'react';
import {FlatList, ListRenderItemInfo, StyleSheet} from 'react-native';
import {fetchNewThreads, fetchRefreshThreads, fetchThreads} from '@actions/remote/thread';
import {loadEarlierThreads, syncTeamThreads} from '@actions/remote/thread';
import Loading from '@components/loading';
import {General, Screens} from '@constants';
import {useServerUrl} from '@context/server';
@@ -57,6 +57,7 @@ const ThreadsList = ({
const serverUrl = useServerUrl();
const theme = useTheme();
const flatListRef = useRef<FlatList<ThreadModel>>(null);
const hasFetchedOnce = useRef(false);
const [isLoading, setIsLoading] = useState(false);
const [endReached, setEndReached] = useState(false);
@@ -66,22 +67,21 @@ const ThreadsList = ({
const lastThread = threads?.length > 0 ? threads[threads.length - 1] : null;
useEffect(() => {
// This is to be called only when there are no threads
if (tab === 'all' && noThreads && !hasFetchedOnce.current) {
setIsLoading(true);
fetchThreads(serverUrl, teamId).finally(() => {
hasFetchedOnce.current = true;
setIsLoading(false);
});
if (hasFetchedOnce.current || tab !== 'all') {
return;
}
}, [noThreads, serverUrl, tab]);
useEffect(() => {
// This is to be called when threads already exist locally and to fetch the latest threads
// Display loading only when there are no threads
if (!noThreads) {
fetchNewThreads(serverUrl, teamId);
setIsLoading(true);
}
}, [noThreads, serverUrl, teamId]);
syncTeamThreads(serverUrl, teamId).then(() => {
hasFetchedOnce.current = true;
});
if (!noThreads) {
setIsLoading(false);
}
}, [noThreads, serverUrl, tab, teamId]);
const listEmptyComponent = useMemo(() => {
if (isLoading) {
@@ -118,33 +118,33 @@ const ThreadsList = ({
return null;
}, [isLoading, tab, theme, endReached]);
const handleTabChange = useCallback((value: GlobalThreadsTab) => {
setTab(value);
flatListRef.current?.scrollToOffset({animated: true, offset: 0});
}, [setTab]);
const handleRefresh = useCallback(() => {
setRefreshing(true);
fetchRefreshThreads(serverUrl, teamId, tab === 'unreads').finally(() => {
syncTeamThreads(serverUrl, teamId).finally(() => {
setRefreshing(false);
});
}, [serverUrl, teamId]);
const handleEndReached = useCallback(() => {
if (!lastThread || tab === 'unreads' || endReached) {
if (tab === 'unreads' || endReached || !lastThread) {
return;
}
const options = {
before: lastThread.id,
perPage: General.CRT_CHUNK_SIZE,
};
setIsLoading(true);
fetchThreads(serverUrl, teamId, options).then((response) => {
if ('data' in response) {
setEndReached(response.data.threads.length < General.CRT_CHUNK_SIZE);
loadEarlierThreads(serverUrl, teamId, lastThread.id).then((response) => {
if (response.threads) {
setEndReached(response.threads.length < General.CRT_CHUNK_SIZE);
}
}).finally(() => {
setIsLoading(false);
});
}, [endReached, lastThread?.id, serverUrl, tab, teamId]);
}, [endReached, serverUrl, tab, teamId, lastThread]);
const renderItem = useCallback(({item}: ListRenderItemInfo<ThreadModel>) => (
<Thread
@@ -158,7 +158,7 @@ const ThreadsList = ({
return (
<>
<Header
setTab={setTab}
setTab={handleTabChange}
tab={tab}
teamId={teamId}
testID={`${testID}.header`}
@@ -172,6 +172,7 @@ const ThreadsList = ({
maxToRenderPerBatch={10}
onEndReached={handleEndReached}
onRefresh={handleRefresh}
ref={flatListRef}
refreshing={isRefreshing}
removeClippedSubviews={true}
renderItem={renderItem}

View File

@@ -23,3 +23,15 @@ export function processIsCRTEnabled(preferences: PreferenceModel[]|PreferenceTyp
configValue === Config.ALWAYS_ON
);
}
export const getThreadsListEdges = (threads: Thread[]) => {
// Sort a clone of 'threads' array by last_reply_at
const sortedThreads = [...threads].sort((a, b) => {
return a.last_reply_at - b.last_reply_at;
});
const earliestThread = sortedThreads[0];
const latestThread = sortedThreads[sortedThreads.length - 1];
return {earliestThread, latestThread};
};

View File

@@ -90,7 +90,6 @@ export type HandleThreadsArgs = {
threads?: ThreadWithLastFetchedAt[];
prepareRecordsOnly?: boolean;
teamId?: string;
loadedInGlobalThreads?: boolean;
};
export type HandleThreadParticipantsArgs = {
@@ -102,7 +101,11 @@ export type HandleThreadParticipantsArgs = {
export type HandleThreadInTeamArgs = {
threadsMap?: Record<string, Thread[]>;
prepareRecordsOnly?: boolean;
loadedInGlobalThreads?: boolean;
};
export type HandleTeamThreadsSyncArgs = {
data: TeamThreadsSync[];
prepareRecordsOnly?: boolean;
};
export type SanitizeReactionsArgs = {

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type TeamModel from './team';
import type {Relation, Model} from '@nozbe/watermelondb';
import type {Associations} from '@nozbe/watermelondb/Model';
/**
* ThreadInTeam model helps us to sync threads without creating any gaps between the threads
* by keeping track of the latest and earliest last_replied_at timestamps loaded for a team.
*/
declare class TeamThreadsSyncModel extends Model {
/** table (name) : TeamThreadsSync */
static table: string;
/** associations : Describes every relationship to this table. */
static associations: Associations;
/** earliest: Oldest last_replied_at loaded through infinite loading */
earliest: number;
/** latest: Newest last_replied_at loaded during app init / navigating to global threads / pull to refresh */
latest: number;
/** team : The related record to the parent Team model */
team: Relation<TeamModel>;
}
export default TeamThreadsSyncModel;

View File

@@ -24,9 +24,6 @@ declare class ThreadInTeamModel extends Model {
/** teamId: Associated thread identifier */
teamId: string;
/** loaded_in_global_threads : Flag to differentiate the unread threads loaded for showing unread counts/mentions */
loadedInGlobalThreads: boolean;
/** thread : The related record to the parent Thread model */
thread: Relation<ThreadModel>;

View File

@@ -91,7 +91,12 @@ type TermsOfService = {
type ThreadInTeam = {
thread_id: string;
team_id: string;
loaded_in_global_threads: boolean;
};
type TeamThreadsSync = {
id: string;
earliest: number;
latest: number;
};
type RawValue =
@@ -125,5 +130,6 @@ type RawValue =
| ThreadWithLastFetchedAt
| ThreadInTeam
| ThreadParticipant
| TeamThreadsSync
| UserProfile
| Pick<ChannelMembership, 'channel_id' | 'user_id'>