[MM-67507] Fix threads not being refetch when switching translations (#9512)

* [MM-67507] Fix threads not being refetch when switching translations

* Fix test
This commit is contained in:
Daniel Espino García 2026-02-17 16:07:10 +01:00 committed by GitHub
parent 9793383832
commit 058d080e9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 178 additions and 13 deletions

View file

@ -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);

View file

@ -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};

View file

@ -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 () => {

View file

@ -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<Client> = {
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();
});
});

View file

@ -530,3 +530,53 @@ export const loadEarlierThreads = async (serverUrl: string, teamId: string, last
return {error};
}
};
const handlePostDeletedForChannelIfNeededBatch: Record<string, {
timeout: NodeJS.Timeout | undefined;
teamIds: Set<string>;
}> = {};
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);
}
};

View file

@ -279,7 +279,7 @@ const ChannelHandler = <TBase extends Constructor<ServerDataOperatorBase>>(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);
}

View file

@ -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;