diff --git a/app/actions/local/channel.test.ts b/app/actions/local/channel.test.ts index 07faf3989..c542c06bc 100644 --- a/app/actions/local/channel.test.ts +++ b/app/actions/local/channel.test.ts @@ -821,7 +821,7 @@ describe('updateMyChannelFromWebsocket', () => { await updateMyChannelFromWebsocket(serverUrl, {...channelMember, autotranslation_disabled: true}, false); - expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId}); + expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId, teamId: channel.team_id}); }); it('updates member autotranslation from channelMember', async () => { @@ -1226,7 +1226,7 @@ describe('deletePostsForChannel', () => { expect(error).toBeFalsy(); expect(models.length).toBeGreaterThan(0); expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith({serverUrl, channelId}); + expect(listener).toHaveBeenCalledWith({serverUrl, channelId, teamId: channel.team_id}); const myChannel = await getMyChannel(operator.database, channelId); expect(myChannel?.lastFetchedAt).toBe(0); diff --git a/app/actions/local/channel.ts b/app/actions/local/channel.ts index 9317d3ec5..f24ab237b 100644 --- a/app/actions/local/channel.ts +++ b/app/actions/local/channel.ts @@ -540,7 +540,7 @@ export async function deletePostsForChannel(serverUrl: string, channelId: string if (preparedModels.length && !prepareRecordsOnly) { await operator.batchRecords(preparedModels, 'deletePostsForChannel'); - DeviceEventEmitter.emit(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId}); + DeviceEventEmitter.emit(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId, teamId: channel.teamId}); } return {error: false, models: preparedModels}; diff --git a/app/actions/remote/channel.test.ts b/app/actions/remote/channel.test.ts index fee3127e0..c22016adf 100644 --- a/app/actions/remote/channel.test.ts +++ b/app/actions/remote/channel.test.ts @@ -786,7 +786,7 @@ describe('setChannelAutotranslation', () => { expect(result.channel!.autotranslation).toBe(false); expect(mockClient.setChannelAutotranslation).toHaveBeenCalledWith(channelId, false); - expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId}); + expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId, teamId}); }); it('should handle client error', async () => { @@ -814,7 +814,7 @@ describe('setMyChannelAutotranslation', () => { expect(result.error).toBeUndefined(); expect(result.data).toBe(true); expect(mockClient.setMyChannelAutotranslation).toHaveBeenCalledWith(channelId, false); - expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId}); + expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId, teamId}); }); it('should handle client error', async () => { diff --git a/app/actions/remote/thread.test.ts b/app/actions/remote/thread.test.ts index 54c943dd5..d0288e2d5 100644 --- a/app/actions/remote/thread.test.ts +++ b/app/actions/remote/thread.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -/* eslint-disable max-lines */ +import {waitFor} from '@testing-library/react-native'; import {ActionType} from '@constants'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; @@ -20,8 +20,10 @@ import { loadEarlierThreads, fetchAndSwitchToThread, syncThreadsIfNeeded, + batchTeamThreadSync, } from './thread'; +import type {Client} from '@client/rest'; import type ServerDataOperator from '@database/operator/server_data_operator'; const serverUrl = 'baseHandler.test.com'; @@ -62,14 +64,15 @@ const throwFunc = () => { throw Error('error'); }; -const mockClient = { - getThread: jest.fn((userId: string, _teamId: string, threadId: string) => ({...thread1, id: threadId})), +const partialClient: Partial = { + getThread: jest.fn((userId: string, _teamId: string, threadId: string) => Promise.resolve({...thread1, id: threadId})), updateTeamThreadsAsRead: jest.fn(), markThreadAsRead: jest.fn(), markThreadAsUnread: jest.fn(), updateThreadFollow: jest.fn(), - getThreads: jest.fn(() => ({threads})), + getThreads: jest.fn(() => Promise.resolve({threads, total: threads.length, total_unread_mentions: 0, total_unread_threads: 0})), }; +const mockClient = partialClient as Client; let mockGetIsCRTEnabled: jest.Mock; jest.mock('@queries/servers/thread', () => { @@ -82,7 +85,7 @@ jest.mock('@queries/servers/thread', () => { }); beforeAll(() => { - // eslint-disable-next-line + // @ts-ignore NetworkManager.getClient = () => mockClient; }); @@ -98,7 +101,7 @@ afterEach(async () => { describe('get threads', () => { it('fetchThread - handle error', async () => { - mockClient.getThread.mockImplementationOnce(jest.fn(throwFunc)); + jest.mocked(mockClient.getThread).mockImplementationOnce(jest.fn(throwFunc)); const result = await fetchThread(serverUrl, teamId, thread1.id); expect(result).toBeDefined(); expect(result.error).toBeDefined(); @@ -229,7 +232,7 @@ describe('get threads', () => { describe('update threads', () => { it('updateTeamThreadsAsRead - handle error', async () => { - mockClient.updateTeamThreadsAsRead.mockImplementationOnce(jest.fn(throwFunc)); + jest.mocked(mockClient.updateTeamThreadsAsRead).mockImplementationOnce(jest.fn(throwFunc)); const result = await updateTeamThreadsAsRead(serverUrl, teamId); expect(result).toBeDefined(); expect(result.error).toBeDefined(); @@ -302,3 +305,109 @@ describe('update threads', () => { expect(result.error).toBeUndefined(); }); }); + +describe('batchTeamThreadSync', () => { + // We don't use fake timers since they break the + // database operations. + + beforeEach(() => { + jest.mocked(mockClient.getThreads).mockClear(); + }); + + const teamFilter = (id: string) => { + return (call: unknown[]) => call[1] === id && call[6] === undefined; + }; + + // fetchThreads calls twice the function, one for the unreads and one for + // the latests. We expect that part to work correctly and if one is called + // for unreads, the one for the latest is also called. + const allCallsFilter = (call: unknown[]) => call[6] === undefined; + + it('returns early when CRT is disabled', async () => { + mockGetIsCRTEnabled.mockResolvedValueOnce(false); + + await batchTeamThreadSync(serverUrl, teamId); + + await waitFor(() => expect(mockClient.getThreads).not.toHaveBeenCalled(), {timeout: 600}); + }); + + it('schedules sync after timeout when CRT is enabled', async () => { + mockGetIsCRTEnabled.mockResolvedValue(true); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + await operator.handleUsers({users: [user1], prepareRecordsOnly: false}); + + await batchTeamThreadSync(serverUrl, teamId); + expect(mockClient.getThreads).not.toHaveBeenCalled(); + + await waitFor(() => expect(mockClient.getThreads).toHaveBeenCalled()); + const getThreadsCalls = jest.mocked(mockClient.getThreads).mock.calls; + expect(getThreadsCalls.filter(teamFilter(teamId))).toHaveLength(1); + expect(getThreadsCalls.filter(allCallsFilter)).toHaveLength(1); + }); + + it('batches multiple teamIds and syncs each after timeout', async () => { + mockGetIsCRTEnabled.mockResolvedValue(true); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + await operator.handleUsers({users: [user1], prepareRecordsOnly: false}); + + await batchTeamThreadSync(serverUrl, teamId); + await batchTeamThreadSync(serverUrl, 'teamid2'); + expect(mockClient.getThreads).not.toHaveBeenCalled(); + + await waitFor(() => expect(mockClient.getThreads).toHaveBeenCalled()); + const getThreadsCalls = jest.mocked(mockClient.getThreads).mock.calls; + expect(getThreadsCalls.filter(teamFilter(teamId))).toHaveLength(1); + expect(getThreadsCalls.filter(teamFilter('teamid2'))).toHaveLength(1); + expect(getThreadsCalls.filter(allCallsFilter)).toHaveLength(2); + }); + + it('calling several times for the same team id does not create multiple calls', async () => { + mockGetIsCRTEnabled.mockResolvedValue(true); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + await operator.handleUsers({users: [user1], prepareRecordsOnly: false}); + + await batchTeamThreadSync(serverUrl, teamId); + await batchTeamThreadSync(serverUrl, teamId); + + await waitFor(() => expect(mockClient.getThreads).toHaveBeenCalled()); + const getThreadsCalls = jest.mocked(mockClient.getThreads).mock.calls; + expect(getThreadsCalls.filter(teamFilter(teamId))).toHaveLength(1); + expect(getThreadsCalls.filter(allCallsFilter)).toHaveLength(1); + }); + + it('includes direct channels when empty teamId is in batch only once', async () => { + mockGetIsCRTEnabled.mockResolvedValue(true); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + await operator.handleUsers({users: [user1], prepareRecordsOnly: false}); + + await batchTeamThreadSync(serverUrl, ''); + await batchTeamThreadSync(serverUrl, teamId); + await batchTeamThreadSync(serverUrl, 'teamid2'); + await batchTeamThreadSync(serverUrl, 'teamid3'); + + await waitFor(() => expect(mockClient.getThreads).toHaveBeenCalled()); + expect(mockClient.getThreads).toHaveBeenCalled(); + const getThreadsCalls = jest.mocked(mockClient.getThreads).mock.calls; + expect(getThreadsCalls.filter(teamFilter(teamId)).length).toBe(1); + expect(getThreadsCalls.filter(teamFilter('teamid2')).length).toBe(1); + expect(getThreadsCalls.filter(teamFilter('teamid3')).length).toBe(1); + expect(getThreadsCalls.filter(allCallsFilter)).toHaveLength(3); + expect(getThreadsCalls.filter((call) => call[10] === false && call[6] === undefined).length).toBe(1); + }); + + it('logs error when syncTeamThreads returns error', async () => { + mockGetIsCRTEnabled.mockResolvedValue(true); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + await operator.handleUsers({users: [user1], prepareRecordsOnly: false}); + const logErrorSpy = jest.spyOn(require('@utils/log'), 'logError').mockImplementation(() => {}); + jest.mocked(mockClient.getThreads).mockImplementationOnce(() => { + throw new Error('sync failed'); + }); + + await batchTeamThreadSync(serverUrl, teamId); + + await waitFor(() => expect(logErrorSpy).toHaveBeenCalled()); + expect(logErrorSpy).toHaveBeenCalledWith('batchTeamThreadSync: Error', expect.any(Error)); + logErrorSpy.mockRestore(); + }); +}); diff --git a/app/actions/remote/thread.ts b/app/actions/remote/thread.ts index f3e919561..3c7a0e1b3 100644 --- a/app/actions/remote/thread.ts +++ b/app/actions/remote/thread.ts @@ -530,3 +530,53 @@ export const loadEarlierThreads = async (serverUrl: string, teamId: string, last return {error}; } }; + +const handlePostDeletedForChannelIfNeededBatch: Record; +}> = {}; + +const handlePostDeletedForChannelIfNeededBatchTimeout = 500; + +export const batchTeamThreadSync = async (serverUrl: string, teamId: string) => { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const isCRTEnabled = await getIsCRTEnabled(database); + if (!isCRTEnabled) { + return; + } + + let existingBatch = handlePostDeletedForChannelIfNeededBatch[serverUrl]; + if (!existingBatch) { + existingBatch = { + timeout: undefined, + teamIds: new Set(), + }; + handlePostDeletedForChannelIfNeededBatch[serverUrl] = existingBatch; + } + + clearTimeout(existingBatch.timeout); + + existingBatch.teamIds.add(teamId); + + existingBatch.timeout = setTimeout(async () => { + const batch = handlePostDeletedForChannelIfNeededBatch[serverUrl]; + if (!batch) { + return; + } + delete handlePostDeletedForChannelIfNeededBatch[serverUrl]; + + const includeDirect = batch.teamIds.has(''); + const teamIds = Array.from(batch.teamIds).filter((t) => t !== ''); + const promises = teamIds.map((t, idx) => syncTeamThreads(serverUrl, t, {excludeDirect: !includeDirect || idx !== 0, refresh: true, fetchOnly: false})); + const results = await Promise.all(promises); + for (const r of results) { + if (r.error) { + logError('batchTeamThreadSync: Error', r.error); + } + } + }, handlePostDeletedForChannelIfNeededBatchTimeout); + } catch (error) { + logError('handlePostDeletedForChannelIfNeeded: Error', error); + } +}; diff --git a/app/database/operator/server_data_operator/handlers/channel.ts b/app/database/operator/server_data_operator/handlers/channel.ts index 1d81a2e2c..918b7ec80 100644 --- a/app/database/operator/server_data_operator/handlers/channel.ts +++ b/app/database/operator/server_data_operator/handlers/channel.ts @@ -279,7 +279,7 @@ const ChannelHandler = >(super const lastPostAt = isCRT ? (chan.last_root_post_at || chan.last_post_at) : chan.last_post_at; if ((chan && e.lastPostAt < lastPostAt) || e.isUnread !== my.is_unread || e.lastViewedAt < my.last_viewed_at || - e.roles !== my.roles + e.roles !== my.roles || e.autotranslationDisabled !== my.autotranslation_disabled ) { res.push(my); } diff --git a/app/managers/global_event_handler.ts b/app/managers/global_event_handler.ts index 9191d8261..f9a0777dc 100644 --- a/app/managers/global_event_handler.ts +++ b/app/managers/global_event_handler.ts @@ -7,6 +7,7 @@ import {Alert, DeviceEventEmitter, Linking, NativeEventEmitter} from 'react-nati import semver from 'semver'; import {switchToChannelById} from '@actions/remote/channel'; +import {batchTeamThreadSync} from '@actions/remote/thread'; import {Device, Events, Sso} from '@constants'; import {MIN_REQUIRED_VERSION} from '@constants/supported_server'; import DatabaseManager from '@database/manager'; @@ -46,6 +47,7 @@ class GlobalEventHandlerSingleton { DeviceEventEmitter.addListener(Events.SERVER_VERSION_CHANGED, this.onServerVersionChanged); splitViewEmitter.addListener('SplitViewChanged', this.onSplitViewChanged); Linking.addEventListener('url', this.onDeepLink); + DeviceEventEmitter.addListener(Events.POST_DELETED_FOR_CHANNEL, this.onPostDeletedForChannel); } init = () => { @@ -53,6 +55,10 @@ class GlobalEventHandlerSingleton { this.JavascriptAndNativeErrorHandler?.initializeErrorHandling(); }; + onPostDeletedForChannel = async ({serverUrl, teamId}: {serverUrl: string; teamId: string}) => { + batchTeamThreadSync(serverUrl, teamId); + }; + onDeepLink = async (event: LinkingCallbackArg) => { if (event.url?.startsWith(Sso.REDIRECT_URL_SCHEME) || event.url?.startsWith(Sso.REDIRECT_URL_SCHEME_DEV)) { return;