[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 {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const models: Model[] = []; const models: Model[] = [];
if (post.root_id) { 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); const {model: threadModel} = await updateThread(serverUrl, post.root_id, {reply_count: post.reply_count}, true);
if (threadModel) { if (threadModel) {
models.push(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" // 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 { try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const currentUserId = await getCurrentUserId(database); const currentUserId = await getCurrentUserId(database);
@@ -236,7 +236,6 @@ export async function processReceivedThreads(serverUrl: string, threads: Thread[
threads: threadsToHandle, threads: threadsToHandle,
teamId, teamId,
prepareRecordsOnly: true, prepareRecordsOnly: true,
loadedInGlobalThreads,
}); });
const models = [...postModels, ...threadModels]; const models = [...postModels, ...threadModels];
@@ -328,3 +327,17 @@ export async function updateThread(serverUrl: string, threadId: string, updatedT
return {error}; 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 {fetchRoles} from '@actions/remote/role';
import {fetchConfigAndLicense} from '@actions/remote/systems'; import {fetchConfigAndLicense} from '@actions/remote/systems';
import {fetchAllTeams, fetchMyTeams, fetchTeamsChannelsAndUnreadPosts, MyTeamsRequest} from '@actions/remote/team'; 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 {autoUpdateTimezone, fetchMe, MyUserRequest, updateAllUsersSince} from '@actions/remote/user';
import {gqlAllChannels} from '@client/graphQL/entry'; import {gqlAllChannels} from '@client/graphQL/entry';
import {General, Preferences, Screens} from '@constants'; import {General, Preferences, Screens} from '@constants';
@@ -28,6 +28,7 @@ import {prepareModels, truncateCrtRelatedTables} from '@queries/servers/entry';
import {getHasCRTChanged} from '@queries/servers/preference'; import {getHasCRTChanged} from '@queries/servers/preference';
import {getConfig, getCurrentUserId, getPushVerificationStatus, getWebSocketLastDisconnected} from '@queries/servers/system'; import {getConfig, getCurrentUserId, getPushVerificationStatus, getWebSocketLastDisconnected} from '@queries/servers/system';
import {deleteMyTeams, getAvailableTeamIds, getTeamChannelHistory, queryMyTeams, queryMyTeamsByIds, queryTeamsById} from '@queries/servers/team'; import {deleteMyTeams, getAvailableTeamIds, getTeamChannelHistory, queryMyTeams, queryMyTeamsByIds, queryTeamsById} from '@queries/servers/team';
import {getIsCRTEnabled} from '@queries/servers/thread';
import {isDMorGM, sortChannelsByDisplayName} from '@utils/channel'; import {isDMorGM, sortChannelsByDisplayName} from '@utils/channel';
import {getMemberChannelsFromGQLQuery, gqlToClientChannelMembership} from '@utils/graphql'; import {getMemberChannelsFromGQLQuery, gqlToClientChannelMembership} from '@utils/graphql';
import {logDebug} from '@utils/log'; import {logDebug} from '@utils/log';
@@ -327,14 +328,14 @@ export async function restDeferredAppEntryActions(
if (preferences && processIsCRTEnabled(preferences, config.CollapsedThreads, config.FeatureFlagCollapsedThreads)) { if (preferences && processIsCRTEnabled(preferences, config.CollapsedThreads, config.FeatureFlagCollapsedThreads)) {
if (initialTeamId) { if (initialTeamId) {
await fetchNewThreads(serverUrl, initialTeamId, false); await syncTeamThreads(serverUrl, initialTeamId);
} }
if (teamData.teams?.length) { if (teamData.teams?.length) {
for await (const team of teamData.teams) { for await (const team of teamData.teams) {
if (team.id !== initialTeamId) { if (team.id !== initialTeamId) {
// need to await here since GM/DM threads in different teams overlap // 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'; return 'Server database not found';
} }
const {database} = operator;
const response = await gqlAllChannels(serverUrl); const response = await gqlAllChannels(serverUrl);
if ('error' in response) { if ('error' in response) {
return response.error; return response.error;
@@ -415,7 +418,7 @@ const graphQLSyncAllChannelMembers = async (serverUrl: string) => {
return response.errors[0].message; return response.errors[0].message;
} }
const userId = await getCurrentUserId(operator.database); const userId = await getCurrentUserId(database);
const channels = getMemberChannelsFromGQLQuery(response.data); const channels = getMemberChannelsFromGQLQuery(response.data);
const memberships = response.data.channelMembers?.map((m) => gqlToClientChannelMembership(m, userId)); 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 modelPromises = await prepareMyChannelsForTeam(operator, '', channels, memberships, undefined, true);
const models = (await Promise.all(modelPromises)).flat(); const models = (await Promise.all(modelPromises)).flat();
if (models.length) { 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(); const config = await client.getClientConfigOld();
let excludeDirect = false; let excludeDirect = false;
for (const myTeam of myTeams) { for await (const myTeam of myTeams) {
fetchMyChannelsForTeam(serverUrl, myTeam.id, false, 0, false, excludeDirect); fetchMyChannelsForTeam(serverUrl, myTeam.id, false, 0, false, excludeDirect);
excludeDirect = true; excludeDirect = true;
if (preferences && processIsCRTEnabled(preferences, config.CollapsedThreads, config.FeatureFlagCollapsedThreads)) { 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 { } catch {

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, Cha
GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel,
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel,
SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
ThreadModel, ThreadParticipantModel, ThreadInTeamModel, UserModel, ConfigModel, ThreadModel, ThreadParticipantModel, ThreadInTeamModel, TeamThreadsSyncModel, UserModel, ConfigModel,
} from '@database/models/server'; } from '@database/models/server';
import AppDataOperator from '@database/operator/app_data_operator'; import AppDataOperator from '@database/operator/app_data_operator';
import ServerDataOperator from '@database/operator/server_data_operator'; import ServerDataOperator from '@database/operator/server_data_operator';
@@ -50,7 +50,7 @@ class DatabaseManager {
GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel,
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel,
SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
ThreadModel, ThreadParticipantModel, ThreadInTeamModel, UserModel, ThreadModel, ThreadParticipantModel, ThreadInTeamModel, TeamThreadsSyncModel, UserModel,
]; ];
this.databaseDirectory = Platform.OS === 'ios' ? getIOSAppGroupDetails().appGroupDatabase : `${FileSystem.DocumentDirectoryPath}/databases/`; 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 {MM_TABLES} from '@constants/database';
import {tableSchemaSpec as configSpec} from '@database/schema/server/table_schemas/config'; 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: { const {SERVER: {
GROUP, GROUP,
MY_CHANNEL, MY_CHANNEL,
TEAM, TEAM,
THREAD, THREAD,
THREAD_PARTICIPANT,
THREADS_IN_TEAM,
USER, USER,
}} = MM_TABLES; }} = MM_TABLES;
export default schemaMigrations({migrations: [ 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, toVersion: 6,
steps: [ steps: [

View File

@@ -31,4 +31,5 @@ export {default as TeamSearchHistoryModel} from './team_search_history';
export {default as ThreadModel} from './thread'; export {default as ThreadModel} from './thread';
export {default as ThreadInTeamModel} from './thread_in_team'; export {default as ThreadInTeamModel} from './thread_in_team';
export {default as ThreadParticipantModel} from './thread_participant'; export {default as ThreadParticipantModel} from './thread_participant';
export {default as TeamThreadsSyncModel} from './team_threads_sync';
export {default as UserModel} from './user'; 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 */ /** threads : Threads list belonging to a team */
@lazy threadsList = this.collections.get<ThreadModel>(THREAD).query( @lazy threadsList = this.collections.get<ThreadModel>(THREAD).query(
Q.on(THREADS_IN_TEAM, Q.and( Q.on(THREADS_IN_TEAM, 'team_id', this.id),
Q.where('team_id', this.id),
Q.where('loaded_in_global_threads', true),
)),
Q.and( Q.and(
Q.where('reply_count', Q.gt(0)), Q.where('reply_count', Q.gt(0)),
Q.where('is_following', true), 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 */ /** team_id: Associated team identifier */
@field('team_id') teamId!: string; @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(THREAD, 'thread_id') thread!: Relation<ThreadModel>;
@immutableRelation(TEAM, 'team_id') team!: Relation<TeamModel>; @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. // See LICENSE.txt for license information.
import DatabaseManager from '@database/manager'; 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 '..'; import ServerDataOperator from '..';
@@ -51,7 +51,7 @@ describe('*** Operator: Thread Handlers tests ***', () => {
] as ThreadWithLastFetchedAt[]; ] as ThreadWithLastFetchedAt[];
const threadsMap = {team_id_1: threads}; 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({ expect(spyOnHandleRecords).toHaveBeenCalledWith({
fieldName: 'id', fieldName: 'id',
@@ -76,7 +76,6 @@ describe('*** Operator: Thread Handlers tests ***', () => {
expect(spyOnHandleThreadInTeam).toHaveBeenCalledWith({ expect(spyOnHandleThreadInTeam).toHaveBeenCalledWith({
threadsMap, threadsMap,
prepareRecordsOnly: true, prepareRecordsOnly: true,
loadedInGlobalThreads: false,
}); });
// Only one batch operation for both tables // Only one batch operation for both tables
@@ -161,21 +160,77 @@ describe('*** Operator: Thread Handlers tests ***', () => {
team_id_2: team2Threads, team_id_2: team2Threads,
}; };
await operator.handleThreadInTeam({threadsMap, loadedInGlobalThreads: true, prepareRecordsOnly: false}); await operator.handleThreadInTeam({threadsMap, prepareRecordsOnly: false});
expect(spyOnPrepareRecords).toHaveBeenCalledWith({ expect(spyOnPrepareRecords).toHaveBeenCalledWith({
createRaws: [{ 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, transformer: transformThreadInTeamRecord,
updateRaws: [
expect.objectContaining({
raw: {team_id: 'team_id_1', thread_id: 'thread-1', loaded_in_global_threads: true},
}),
],
tableName: 'ThreadsInTeam', 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; } = MM_TABLES.SERVER;
export interface ThreadHandlerMix { export interface ThreadHandlerMix {
handleThreads: ({threads, teamId, prepareRecordsOnly, loadedInGlobalThreads}: HandleThreadsArgs) => Promise<Model[]>; handleThreads: ({threads, teamId, prepareRecordsOnly}: HandleThreadsArgs) => Promise<Model[]>;
handleThreadParticipants: ({threadsParticipants, prepareRecordsOnly}: HandleThreadParticipantsArgs) => Promise<ThreadParticipantModel[]>; handleThreadParticipants: ({threadsParticipants, prepareRecordsOnly}: HandleThreadParticipantsArgs) => Promise<ThreadParticipantModel[]>;
} }
@@ -37,7 +37,7 @@ const ThreadHandler = (superclass: any) => class extends superclass {
* @param {boolean | undefined} handleThreads.prepareRecordsOnly * @param {boolean | undefined} handleThreads.prepareRecordsOnly
* @returns {Promise<void>} * @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) { if (!threads?.length) {
logWarning( logWarning(
'An empty or undefined "threads" array has been passed to the handleThreads method', '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({ const threadsInTeam = await this.handleThreadInTeam({
threadsMap: {[teamId]: threads}, threadsMap: {[teamId]: threads},
prepareRecordsOnly: true, prepareRecordsOnly: true,
loadedInGlobalThreads,
}) as ThreadInTeamModel[]; }) as ThreadInTeamModel[];
batch.push(...threadsInTeam); batch.push(...threadsInTeam);
} }

View File

@@ -5,20 +5,20 @@ import {Q, Database} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database'; import {MM_TABLES} from '@constants/database';
import {transformThreadInTeamRecord} from '@database/operator/server_data_operator/transformers/thread'; 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 {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'; import type ThreadInTeamModel from '@typings/database/models/servers/thread_in_team';
export interface ThreadInTeamHandlerMix { export interface ThreadInTeamHandlerMix {
handleThreadInTeam: ({threadsMap, loadedInGlobalThreads, prepareRecordsOnly}: HandleThreadInTeamArgs) => Promise<ThreadInTeamModel[]>; handleThreadInTeam: ({threadsMap, prepareRecordsOnly}: HandleThreadInTeamArgs) => Promise<ThreadInTeamModel[]>;
} }
const {THREADS_IN_TEAM} = MM_TABLES.SERVER; const {THREADS_IN_TEAM} = MM_TABLES.SERVER;
const ThreadInTeamHandler = (superclass: any) => class extends superclass { 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) { if (!threadsMap || !Object.keys(threadsMap).length) {
logWarning( logWarning(
'An empty or undefined "threadsMap" object has been passed to the handleReceivedPostForChannel method', '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 []; return [];
} }
const update: RecordPair[] = [];
const create: ThreadInTeam[] = []; const create: ThreadInTeam[] = [];
const teamIds = Object.keys(threadsMap); const teamIds = Object.keys(threadsMap);
for await (const teamId of teamIds) { 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( const chunks = await (this.database as Database).get<ThreadInTeamModel>(THREADS_IN_TEAM).query(
Q.where('team_id', teamId), Q.where('team_id', teamId),
Q.where('id', Q.oneOf(threadIds)),
).fetch(); ).fetch();
const chunksMap = chunks.reduce((result: Record<string, ThreadInTeamModel>, chunk) => { const chunksMap = chunks.reduce((result: Record<string, ThreadInTeamModel>, chunk) => {
result[chunk.threadId] = chunk; result[chunk.threadId] = chunk;
@@ -41,29 +42,18 @@ const ThreadInTeamHandler = (superclass: any) => class extends superclass {
for (const thread of threadsMap[teamId]) { for (const thread of threadsMap[teamId]) {
const chunk = chunksMap[thread.id]; const chunk = chunksMap[thread.id];
const newValue = { // Create if the chunk is not found
thread_id: thread.id, if (!chunk) {
team_id: teamId, create.push({
loaded_in_global_threads: Boolean(loadedInGlobalThreads), thread_id: thread.id,
}; team_id: teamId,
});
// 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);
} }
} }
} }
const threadsInTeam = (await this.prepareRecords({ const threadsInTeam = (await this.prepareRecords({
createRaws: getRawRecordPairs(create), createRaws: getRawRecordPairs(create),
updateRaws: update,
transformer: transformThreadInTeamRecord, transformer: transformThreadInTeamRecord,
tableName: THREADS_IN_TEAM, tableName: THREADS_IN_TEAM,
})) as ThreadInTeamModel[]; })) 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 PostsInThreadHandler, {PostsInThreadHandlerMix} from '@database/operator/server_data_operator/handlers/posts_in_thread';
import ReactionHander, {ReactionHandlerMix} from '@database/operator/server_data_operator/handlers/reaction'; import ReactionHander, {ReactionHandlerMix} from '@database/operator/server_data_operator/handlers/reaction';
import TeamHandler, {TeamHandlerMix} from '@database/operator/server_data_operator/handlers/team'; 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 ThreadHandler, {ThreadHandlerMix} from '@database/operator/server_data_operator/handlers/thread';
import ThreadInTeamHandler, {ThreadInTeamHandlerMix} from '@database/operator/server_data_operator/handlers/thread_in_team'; import ThreadInTeamHandler, {ThreadInTeamHandlerMix} from '@database/operator/server_data_operator/handlers/thread_in_team';
import UserHandler, {UserHandlerMix} from '@database/operator/server_data_operator/handlers/user'; import UserHandler, {UserHandlerMix} from '@database/operator/server_data_operator/handlers/user';
@@ -29,6 +30,7 @@ interface ServerDataOperator extends
TeamHandlerMix, TeamHandlerMix,
ThreadHandlerMix, ThreadHandlerMix,
ThreadInTeamHandlerMix, ThreadInTeamHandlerMix,
TeamThreadsSyncHandlerMix,
UserHandlerMix UserHandlerMix
{} {}
@@ -43,6 +45,7 @@ class ServerDataOperator extends mix(ServerDataOperatorBase).with(
TeamHandler, TeamHandler,
ThreadHandler, ThreadHandler,
ThreadInTeamHandler, ThreadInTeamHandler,
TeamThreadsSyncHandler,
UserHandler, UserHandler,
) { ) {
// eslint-disable-next-line no-useless-constructor // 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 {prepareBaseRecord} from '@database/operator/server_data_operator/transformers/index';
import type {TransformerArgs} from '@typings/database/database'; 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 ThreadModel from '@typings/database/models/servers/thread';
import type ThreadInTeamModel from '@typings/database/models/servers/thread_in_team'; import type ThreadInTeamModel from '@typings/database/models/servers/thread_in_team';
import type ThreadParticipantModel from '@typings/database/models/servers/thread_participant'; import type ThreadParticipantModel from '@typings/database/models/servers/thread_participant';
@@ -13,6 +14,7 @@ const {
THREAD, THREAD,
THREAD_PARTICIPANT, THREAD_PARTICIPANT,
THREADS_IN_TEAM, THREADS_IN_TEAM,
TEAM_THREADS_SYNC,
} = MM_TABLES.SERVER; } = 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 // 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) => { const fieldsMapper = (thread: ThreadModel) => {
thread._raw.id = isCreateAction ? (raw?.id ?? thread.id) : record.id; 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.lastViewedAt = raw.last_viewed_at ?? record?.lastViewedAt ?? 0;
thread.replyCount = raw.reply_count; thread.replyCount = raw.reply_count;
thread.isFollowing = raw.is_following ?? record?.isFollowing; 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> => { export const transformThreadInTeamRecord = ({action, database, value}: TransformerArgs): Promise<ThreadInTeamModel> => {
const raw = value.raw as ThreadInTeam; const raw = value.raw as ThreadInTeam;
const record = value.record as ThreadInTeamModel;
const fieldsMapper = (threadInTeam: ThreadInTeamModel) => { const fieldsMapper = (threadInTeam: ThreadInTeamModel) => {
threadInTeam.threadId = raw.thread_id; threadInTeam.threadId = raw.thread_id;
threadInTeam.teamId = raw.team_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({ return prepareBaseRecord({
@@ -94,3 +95,23 @@ export const transformThreadInTeamRecord = ({action, database, value}: Transform
fieldsMapper, fieldsMapper,
}) as Promise<ThreadInTeamModel>; }) 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, ThreadSchema,
ThreadInTeamSchema, ThreadInTeamSchema,
ThreadParticipantSchema, ThreadParticipantSchema,
TeamThreadsSyncSchema,
UserSchema, UserSchema,
} from './table_schemas'; } from './table_schemas';
export const serverSchema: AppSchema = appSchema({ export const serverSchema: AppSchema = appSchema({
version: 6, version: 7,
tables: [ tables: [
CategorySchema, CategorySchema,
CategoryChannelSchema, CategoryChannelSchema,
@@ -67,6 +68,7 @@ export const serverSchema: AppSchema = appSchema({
TeamMembershipSchema, TeamMembershipSchema,
TeamSchema, TeamSchema,
TeamSearchHistorySchema, TeamSearchHistorySchema,
TeamThreadsSyncSchema,
ThreadSchema, ThreadSchema,
ThreadInTeamSchema, ThreadInTeamSchema,
ThreadParticipantSchema, ThreadParticipantSchema,

View File

@@ -30,5 +30,6 @@ export {default as TeamSearchHistorySchema} from './team_search_history';
export {default as ThreadSchema} from './thread'; export {default as ThreadSchema} from './thread';
export {default as ThreadParticipantSchema} from './thread_participant'; export {default as ThreadParticipantSchema} from './thread_participant';
export {default as ThreadInTeamSchema} from './thread_in_team'; 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 UserSchema} from './user';
export {default as ConfigSchema} from './config'; 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 {MM_TABLES} from '@constants/database';
import type {TableSchemaSpec} from '@nozbe/watermelondb/Schema';
const {THREAD} = MM_TABLES.SERVER; const {THREAD} = MM_TABLES.SERVER;
export default tableSchema({ export const tableSchemaSpec: TableSchemaSpec = {
name: THREAD, name: THREAD,
columns: [ columns: [
{name: 'is_following', type: 'boolean'}, {name: 'is_following', type: 'boolean'},
@@ -19,5 +21,7 @@ export default tableSchema({
{name: 'viewed_at', type: 'number'}, {name: 'viewed_at', type: 'number'},
{name: 'last_fetched_at', type: 'number', isIndexed: true}, {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 {MM_TABLES} from '@constants/database';
import type {TableSchemaSpec} from '@nozbe/watermelondb/Schema';
const {THREADS_IN_TEAM} = MM_TABLES.SERVER; const {THREADS_IN_TEAM} = MM_TABLES.SERVER;
export default tableSchema({ export const tableSchemaSpec: TableSchemaSpec = {
name: THREADS_IN_TEAM, name: THREADS_IN_TEAM,
columns: [ columns: [
{name: 'loaded_in_global_threads', type: 'boolean', isIndexed: true},
{name: 'team_id', type: 'string', isIndexed: true}, {name: 'team_id', type: 'string', isIndexed: true},
{name: 'thread_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 {MM_TABLES} from '@constants/database';
import type {TableSchemaSpec} from '@nozbe/watermelondb/Schema';
const {THREAD_PARTICIPANT} = MM_TABLES.SERVER; const {THREAD_PARTICIPANT} = MM_TABLES.SERVER;
export default tableSchema({ export const tableSchemaSpec: TableSchemaSpec = {
name: THREAD_PARTICIPANT, name: THREAD_PARTICIPANT,
columns: [ columns: [
{name: 'thread_id', type: 'string', isIndexed: true}, {name: 'thread_id', type: 'string', isIndexed: true},
{name: 'user_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,
THREAD_PARTICIPANT, THREAD_PARTICIPANT,
THREADS_IN_TEAM, THREADS_IN_TEAM,
TEAM_THREADS_SYNC,
USER, USER,
} = MM_TABLES.SERVER; } = MM_TABLES.SERVER;
describe('*** Test schema for SERVER database ***', () => { describe('*** Test schema for SERVER database ***', () => {
it('=> The SERVER SCHEMA should strictly match', () => { it('=> The SERVER SCHEMA should strictly match', () => {
expect(serverSchema).toEqual({ expect(serverSchema).toEqual({
version: 6, version: 7,
unsafeSql: undefined, unsafeSql: undefined,
tables: { tables: {
[CATEGORY]: { [CATEGORY]: {
@@ -570,16 +571,26 @@ describe('*** Test schema for SERVER database ***', () => {
name: THREADS_IN_TEAM, name: THREADS_IN_TEAM,
unsafeSql: undefined, unsafeSql: undefined,
columns: { columns: {
loaded_in_global_threads: {name: 'loaded_in_global_threads', type: 'boolean', isIndexed: true},
team_id: {name: 'team_id', type: 'string', isIndexed: true}, team_id: {name: 'team_id', type: 'string', isIndexed: true},
thread_id: {name: 'thread_id', type: 'string', isIndexed: true}, thread_id: {name: 'thread_id', type: 'string', isIndexed: true},
}, },
columnArray: [ columnArray: [
{name: 'loaded_in_global_threads', type: 'boolean', isIndexed: true},
{name: 'team_id', type: 'string', isIndexed: true}, {name: 'team_id', type: 'string', isIndexed: true},
{name: 'thread_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]: { [USER]: {
name: USER, name: USER,
unsafeSql: undefined, unsafeSql: undefined,

View File

@@ -14,10 +14,11 @@ import {getConfig, observeConfigValue} from './system';
import type ServerDataOperator from '@database/operator/server_data_operator'; import type ServerDataOperator from '@database/operator/server_data_operator';
import type Model from '@nozbe/watermelondb/Model'; 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 ThreadModel from '@typings/database/models/servers/thread';
import type UserModel from '@typings/database/models/servers/user'; 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> => { export const getIsCRTEnabled = async (database: Database): Promise<boolean> => {
const config = await getConfig(database); 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) => { export const observeIsCRTEnabled = (database: Database) => {
const cfgValue = observeConfigValue(database, 'CollapsedThreads'); const cfgValue = observeConfigValue(database, 'CollapsedThreads');
const featureFlag = observeConfigValue(database, 'FeatureFlagCollapsedThreads'); const featureFlag = observeConfigValue(database, 'FeatureFlagCollapsedThreads');
@@ -133,7 +139,7 @@ export const prepareThreadsFromReceivedPosts = async (operator: ServerDataOperat
return models; 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[] = []; const query: Q.Clause[] = [];
if (isFollowing) { if (isFollowing) {
@@ -152,38 +158,22 @@ export const queryThreadsInTeam = (database: Database, teamId: string, onlyUnrea
query.push(Q.sortBy('last_reply_at', Q.desc)); 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( query.push(
Q.on(THREADS_IN_TEAM, joinCondition), Q.on(THREADS_IN_TEAM, Q.where('team_id', teamId)),
); );
if (limit) { if (earliest) {
query.push(Q.take(limit)); query.push(Q.where('last_reply_at', Q.gte(earliest)));
} }
return database.get<ThreadModel>(THREAD).query(...query); return database.get<ThreadModel>(THREAD).query(...query);
}; };
export async function getNewestThreadInTeam( export const queryTeamThreadsSync = (database: Database, teamId: string) => {
database: Database, return database.get<TeamThreadsSyncModel>(TEAM_THREADS_SYNC).query(
teamId: string, Q.where('id', teamId),
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 function observeThreadMentionCount(database: Database, teamId?: string, includeDmGm?: boolean): Observable<number> { export function observeThreadMentionCount(database: Database, teamId?: string, includeDmGm?: boolean): Observable<number> {
return observeUnreadsAndMentionsInTeam(database, teamId, includeDmGm).pipe( return observeUnreadsAndMentionsInTeam(database, teamId, includeDmGm).pipe(

View File

@@ -4,9 +4,10 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables'; import withObservables from '@nozbe/with-observables';
import {AppStateStatus} from 'react-native'; import {AppStateStatus} from 'react-native';
import {switchMap} from 'rxjs/operators';
import {observeCurrentTeamId} from '@queries/servers/system'; 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 {observeTeammateNameDisplay} from '@queries/servers/user';
import ThreadsList from './threads_list'; 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 enhanced = withObservables(['tab', 'teamId', 'forceQueryAfterAppState'], ({database, tab, teamId}: Props) => {
const getOnlyUnreads = tab !== 'all'; const getOnlyUnreads = tab !== 'all';
const teamThreadsSyncObserver = queryTeamThreadsSync(database, teamId).observeWithColumns(['earliest']);
return { return {
unreadsCount: queryThreadsInTeam(database, teamId, true).observeCount(false), unreadsCount: queryThreadsInTeam(database, teamId, true).observeCount(false),
teammateNameDisplay: observeTeammateNameDisplay(database), 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 React, {useCallback, useEffect, useMemo, useState, useRef} from 'react';
import {FlatList, ListRenderItemInfo, StyleSheet} from 'react-native'; 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 Loading from '@components/loading';
import {General, Screens} from '@constants'; import {General, Screens} from '@constants';
import {useServerUrl} from '@context/server'; import {useServerUrl} from '@context/server';
@@ -57,6 +57,7 @@ const ThreadsList = ({
const serverUrl = useServerUrl(); const serverUrl = useServerUrl();
const theme = useTheme(); const theme = useTheme();
const flatListRef = useRef<FlatList<ThreadModel>>(null);
const hasFetchedOnce = useRef(false); const hasFetchedOnce = useRef(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [endReached, setEndReached] = useState(false); const [endReached, setEndReached] = useState(false);
@@ -66,22 +67,21 @@ const ThreadsList = ({
const lastThread = threads?.length > 0 ? threads[threads.length - 1] : null; const lastThread = threads?.length > 0 ? threads[threads.length - 1] : null;
useEffect(() => { useEffect(() => {
// This is to be called only when there are no threads if (hasFetchedOnce.current || tab !== 'all') {
if (tab === 'all' && noThreads && !hasFetchedOnce.current) { return;
setIsLoading(true);
fetchThreads(serverUrl, teamId).finally(() => {
hasFetchedOnce.current = true;
setIsLoading(false);
});
} }
}, [noThreads, serverUrl, tab]);
useEffect(() => { // Display loading only when there are no threads
// This is to be called when threads already exist locally and to fetch the latest threads
if (!noThreads) { 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(() => { const listEmptyComponent = useMemo(() => {
if (isLoading) { if (isLoading) {
@@ -118,33 +118,33 @@ const ThreadsList = ({
return null; return null;
}, [isLoading, tab, theme, endReached]); }, [isLoading, tab, theme, endReached]);
const handleTabChange = useCallback((value: GlobalThreadsTab) => {
setTab(value);
flatListRef.current?.scrollToOffset({animated: true, offset: 0});
}, [setTab]);
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
setRefreshing(true); setRefreshing(true);
fetchRefreshThreads(serverUrl, teamId, tab === 'unreads').finally(() => { syncTeamThreads(serverUrl, teamId).finally(() => {
setRefreshing(false); setRefreshing(false);
}); });
}, [serverUrl, teamId]); }, [serverUrl, teamId]);
const handleEndReached = useCallback(() => { const handleEndReached = useCallback(() => {
if (!lastThread || tab === 'unreads' || endReached) { if (tab === 'unreads' || endReached || !lastThread) {
return; return;
} }
const options = {
before: lastThread.id,
perPage: General.CRT_CHUNK_SIZE,
};
setIsLoading(true); setIsLoading(true);
fetchThreads(serverUrl, teamId, options).then((response) => { loadEarlierThreads(serverUrl, teamId, lastThread.id).then((response) => {
if ('data' in response) { if (response.threads) {
setEndReached(response.data.threads.length < General.CRT_CHUNK_SIZE); setEndReached(response.threads.length < General.CRT_CHUNK_SIZE);
} }
}).finally(() => { }).finally(() => {
setIsLoading(false); setIsLoading(false);
}); });
}, [endReached, lastThread?.id, serverUrl, tab, teamId]); }, [endReached, serverUrl, tab, teamId, lastThread]);
const renderItem = useCallback(({item}: ListRenderItemInfo<ThreadModel>) => ( const renderItem = useCallback(({item}: ListRenderItemInfo<ThreadModel>) => (
<Thread <Thread
@@ -158,7 +158,7 @@ const ThreadsList = ({
return ( return (
<> <>
<Header <Header
setTab={setTab} setTab={handleTabChange}
tab={tab} tab={tab}
teamId={teamId} teamId={teamId}
testID={`${testID}.header`} testID={`${testID}.header`}
@@ -172,6 +172,7 @@ const ThreadsList = ({
maxToRenderPerBatch={10} maxToRenderPerBatch={10}
onEndReached={handleEndReached} onEndReached={handleEndReached}
onRefresh={handleRefresh} onRefresh={handleRefresh}
ref={flatListRef}
refreshing={isRefreshing} refreshing={isRefreshing}
removeClippedSubviews={true} removeClippedSubviews={true}
renderItem={renderItem} renderItem={renderItem}

View File

@@ -23,3 +23,15 @@ export function processIsCRTEnabled(preferences: PreferenceModel[]|PreferenceTyp
configValue === Config.ALWAYS_ON 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[]; threads?: ThreadWithLastFetchedAt[];
prepareRecordsOnly?: boolean; prepareRecordsOnly?: boolean;
teamId?: string; teamId?: string;
loadedInGlobalThreads?: boolean;
}; };
export type HandleThreadParticipantsArgs = { export type HandleThreadParticipantsArgs = {
@@ -102,7 +101,11 @@ export type HandleThreadParticipantsArgs = {
export type HandleThreadInTeamArgs = { export type HandleThreadInTeamArgs = {
threadsMap?: Record<string, Thread[]>; threadsMap?: Record<string, Thread[]>;
prepareRecordsOnly?: boolean; prepareRecordsOnly?: boolean;
loadedInGlobalThreads?: boolean; };
export type HandleTeamThreadsSyncArgs = {
data: TeamThreadsSync[];
prepareRecordsOnly?: boolean;
}; };
export type SanitizeReactionsArgs = { 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: Associated thread identifier */
teamId: string; 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 : The related record to the parent Thread model */
thread: Relation<ThreadModel>; thread: Relation<ThreadModel>;

View File

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