Skip to content

Commit e8f1d0c

Browse files
mattermost-buildlarkoxmatthewbirtch
authored
Add autotranslations (#9324) (#9500)
* Add autotranslations * Develop latest changes and missing features * i18n-extract * Fix tests and update api behavior * Fix minor bugs * adjustments to shimmer animation, showtranslation modal style * Update post.tsx * Update show_translation.tsx * adjust sizing and opacity of translate icon * Add channel header translated icon * Disable auto translation item if it is not a supported language * Move channel info to the top * Update edit channel to channel info * Fix align of option items * Fix i18n * Add detox related changes for channel settings changes * Add tests * Address changes around the my channel column change * Address feedback * Fix test * Fix bad import * Fix set my channel autotranslation * Fix test * Address feedback * Add missing files from last commit * Remove unneeded change --------- (cherry picked from commit 23565b5) Co-authored-by: Daniel Espino García <larkox@gmail.com> Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>
1 parent 3edc6c5 commit e8f1d0c

File tree

105 files changed

+3375
-463
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

105 files changed

+3375
-463
lines changed

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ Located at `libraries/@mattermost/`:
143143
144144
## Testing
145145
146+
**Testing guide:** See [docs/testing_guide.md](docs/testing_guide.md) for how to add and structure unit tests.
147+
146148
### Test Organization
147149
- **Jest coverage excludes** `/components/` and `/screens/` directories
148150
- Mock database manager at `app/database/manager/__mocks__/index.ts`

app/actions/local/channel.test.ts

Lines changed: 213 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55

66
import {DeviceEventEmitter} from 'react-native';
77

8-
import {Navigation} from '@constants';
8+
import {ActionType, Events, Navigation} from '@constants';
99
import {SYSTEM_IDENTIFIERS} from '@constants/database';
1010
import DatabaseManager from '@database/manager';
1111
import {getMyChannel} from '@queries/servers/channel';
12+
import {getPostById} from '@queries/servers/post';
1213
import {getCommonSystemValues, getTeamHistory} from '@queries/servers/system';
1314
import {getTeamChannelHistory} from '@queries/servers/team';
1415
import {dismissAllModalsAndPopToRoot, dismissAllModalsAndPopToScreen} from '@screens/navigation';
16+
import TestHelper from '@test/test_helper';
1517

1618
import {
1719
switchToChannel,
@@ -27,6 +29,8 @@ import {
2729
updateChannelsDisplayName,
2830
showUnreadChannelsOnly,
2931
updateDmGmDisplayName,
32+
deletePostsForChannelsWithAutotranslation,
33+
deletePostsForChannel,
3034
} from './channel';
3135

3236
import type {ChannelModel, MyChannelModel, SystemModel} from '@database/models/server';
@@ -770,19 +774,19 @@ describe('updateMyChannelFromWebsocket', () => {
770774
const serverUrl = 'baseHandler.test.com';
771775
const channelId = 'id1';
772776
const teamId = 'tId1';
773-
const channel: Channel = {
777+
const channel: Channel = TestHelper.fakeChannel({
774778
id: channelId,
775779
team_id: teamId,
776780
total_msg_count: 0,
777781
delete_at: 0,
778-
} as Channel;
779-
const channelMember: ChannelMembership = {
782+
});
783+
const channelMember: ChannelMembership = TestHelper.fakeChannelMember({
780784
id: 'id',
781785
user_id: 'userid',
782786
channel_id: channelId,
783787
msg_count: 0,
784788
roles: '',
785-
} as ChannelMembership;
789+
});
786790

787791
beforeEach(async () => {
788792
await DatabaseManager.init([serverUrl]);
@@ -808,6 +812,27 @@ describe('updateMyChannelFromWebsocket', () => {
808812
expect(model).toBeDefined();
809813
expect(model?.roles).toBe('channel_user');
810814
});
815+
816+
it('calls deletePostsForChannel when autotranslation changes', async () => {
817+
const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''});
818+
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
819+
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
820+
await operator.handlePosts({actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, order: [post.id], posts: [post], prepareRecordsOnly: false});
821+
822+
await updateMyChannelFromWebsocket(serverUrl, {...channelMember, autotranslation_disabled: true}, false);
823+
824+
expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId});
825+
});
826+
827+
it('updates member autotranslation from channelMember', async () => {
828+
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
829+
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
830+
831+
await updateMyChannelFromWebsocket(serverUrl, {...channelMember, autotranslation_disabled: true}, false);
832+
833+
const member = await getMyChannel(operator.database, channelId);
834+
expect(member?.autotranslationDisabled).toBe(true);
835+
});
811836
});
812837

813838
describe('updateChannelInfoFromChannel', () => {
@@ -1133,3 +1158,186 @@ describe('updateDmGmDisplayName', () => {
11331158
expect((channels![1] as ChannelModel).displayName).toBe(`${user2.username}, ${user3.username}`);
11341159
});
11351160
});
1161+
1162+
describe('deletePostsForChannel', () => {
1163+
const serverUrl = 'baseHandler.test.com';
1164+
let operator: ServerDataOperator;
1165+
1166+
const channelId = 'channelid1';
1167+
const teamId = 'tId1';
1168+
const channel: Channel = TestHelper.fakeChannel({
1169+
id: channelId,
1170+
team_id: teamId,
1171+
total_msg_count: 0,
1172+
});
1173+
const channelMember: ChannelMembership = TestHelper.fakeChannelMember({
1174+
id: 'id',
1175+
channel_id: channelId,
1176+
msg_count: 0,
1177+
});
1178+
1179+
beforeEach(async () => {
1180+
await DatabaseManager.init([serverUrl]);
1181+
operator = DatabaseManager.serverDatabases[serverUrl]!.operator;
1182+
});
1183+
1184+
afterEach(async () => {
1185+
await DatabaseManager.destroyServerDatabase(serverUrl);
1186+
});
1187+
1188+
it('handle not found database', async () => {
1189+
const {models, error} = await deletePostsForChannel('foo', channelId);
1190+
expect(models).toEqual([]);
1191+
expect(error).toBeTruthy();
1192+
});
1193+
1194+
it('channel not found', async () => {
1195+
const {models, error} = await deletePostsForChannel(serverUrl, 'nonexistent');
1196+
expect(models).toEqual([]);
1197+
expect(error).toBeFalsy();
1198+
});
1199+
1200+
it('channel with no posts', async () => {
1201+
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
1202+
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
1203+
1204+
const {models, error} = await deletePostsForChannel(serverUrl, channelId);
1205+
expect(models).toEqual([]);
1206+
expect(error).toBeFalsy();
1207+
});
1208+
1209+
it('channel with posts - batch written and event emitted', async () => {
1210+
const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''});
1211+
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
1212+
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
1213+
await operator.handlePosts({
1214+
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
1215+
order: [post.id],
1216+
posts: [post],
1217+
prepareRecordsOnly: false,
1218+
});
1219+
1220+
const listener = jest.fn();
1221+
const subscription = DeviceEventEmitter.addListener(Events.POST_DELETED_FOR_CHANNEL, listener);
1222+
1223+
const {models, error} = await deletePostsForChannel(serverUrl, channelId);
1224+
subscription.remove();
1225+
1226+
expect(error).toBeFalsy();
1227+
expect(models.length).toBeGreaterThan(0);
1228+
expect(listener).toHaveBeenCalledTimes(1);
1229+
expect(listener).toHaveBeenCalledWith({serverUrl, channelId});
1230+
1231+
const myChannel = await getMyChannel(operator.database, channelId);
1232+
expect(myChannel?.lastFetchedAt).toBe(0);
1233+
});
1234+
1235+
it('prepareRecordsOnly true - no write and no emit', async () => {
1236+
const post = TestHelper.fakePost({id: 'postid2', channel_id: channelId, root_id: ''});
1237+
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
1238+
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
1239+
await operator.handlePosts({
1240+
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
1241+
order: [post.id],
1242+
posts: [post],
1243+
prepareRecordsOnly: false,
1244+
});
1245+
1246+
const listener = jest.fn();
1247+
const subscription = DeviceEventEmitter.addListener(Events.POST_DELETED_FOR_CHANNEL, listener);
1248+
1249+
const {models, error} = await deletePostsForChannel(serverUrl, channelId, true);
1250+
subscription.remove();
1251+
1252+
expect(error).toBeFalsy();
1253+
expect(models.length).toBeGreaterThan(0);
1254+
expect(listener).not.toHaveBeenCalled();
1255+
1256+
const myChannel = await getMyChannel(operator.database, channelId);
1257+
expect(myChannel?._preparedState).toBe('update');
1258+
1259+
// Batch the records to avoid the invariant error
1260+
await operator.batchRecords([...models], 'test');
1261+
});
1262+
});
1263+
1264+
describe('deletePostsForChannelsWithAutotranslation', () => {
1265+
const serverUrl = 'baseHandler.test.com';
1266+
let operator: ServerDataOperator;
1267+
1268+
const channelId = 'channelid1';
1269+
const teamId = 'tId1';
1270+
const channel: Channel = TestHelper.fakeChannel({
1271+
id: channelId,
1272+
team_id: teamId,
1273+
total_msg_count: 0,
1274+
autotranslation: true,
1275+
});
1276+
1277+
beforeEach(async () => {
1278+
await DatabaseManager.init([serverUrl]);
1279+
operator = DatabaseManager.serverDatabases[serverUrl]!.operator;
1280+
});
1281+
1282+
afterEach(async () => {
1283+
await DatabaseManager.destroyServerDatabase(serverUrl);
1284+
});
1285+
1286+
it('no myChannels with autotranslation - no deletes', async () => {
1287+
const channelMember: ChannelMembership = TestHelper.fakeChannelMember({
1288+
id: 'id',
1289+
channel_id: channelId,
1290+
msg_count: 0,
1291+
autotranslation_disabled: true,
1292+
});
1293+
const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''});
1294+
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
1295+
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
1296+
await operator.handlePosts({
1297+
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
1298+
order: [post.id],
1299+
posts: [post],
1300+
prepareRecordsOnly: false,
1301+
});
1302+
1303+
const {models, error} = await deletePostsForChannelsWithAutotranslation(serverUrl);
1304+
expect(error).toBeUndefined();
1305+
expect(models).toEqual([]);
1306+
1307+
const databasePost = await getPostById(operator.database, post.id);
1308+
expect(databasePost).toBeDefined();
1309+
});
1310+
1311+
it('myChannels with autotranslation - delete posts', async () => {
1312+
const channelMember: ChannelMembership = TestHelper.fakeChannelMember({
1313+
id: 'id',
1314+
channel_id: channelId,
1315+
msg_count: 0,
1316+
autotranslation_disabled: false,
1317+
});
1318+
const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''});
1319+
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
1320+
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
1321+
await operator.handlePosts({
1322+
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
1323+
order: [post.id],
1324+
posts: [post],
1325+
prepareRecordsOnly: false,
1326+
});
1327+
1328+
const {models, error} = await deletePostsForChannelsWithAutotranslation(serverUrl);
1329+
expect(error).toBeUndefined();
1330+
expect(models.length).toBeGreaterThan(0);
1331+
const databasePost = await getPostById(operator.database, post.id);
1332+
expect(databasePost).toBeUndefined();
1333+
});
1334+
1335+
it('handle error', async () => {
1336+
jest.spyOn(DatabaseManager, 'getServerDatabaseAndOperator').mockImplementationOnce(() => {
1337+
throw new Error('DB error');
1338+
});
1339+
const {models, error} = await deletePostsForChannelsWithAutotranslation(serverUrl);
1340+
expect(models).toEqual([]);
1341+
expect(error).toBeTruthy();
1342+
});
1343+
});

app/actions/local/channel.ts

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import {DeviceEventEmitter} from 'react-native';
55

6-
import {General, Navigation as NavigationConstants, Preferences, Screens} from '@constants';
6+
import {Events, General, Navigation as NavigationConstants, Preferences, Screens} from '@constants';
77
import {SYSTEM_IDENTIFIERS} from '@constants/database';
88
import DatabaseManager from '@database/manager';
99
import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
@@ -13,7 +13,9 @@ import {
1313
prepareDeleteChannel, prepareMyChannelsForTeam, queryAllMyChannel,
1414
getMyChannel, getChannelById, queryUsersOnChannel, queryUserChannelsByTypes,
1515
prepareAllMyChannels,
16+
queryMyChannelsWithAutotranslation,
1617
} from '@queries/servers/channel';
18+
import {prepareDeletePost, queryPostsInChannel, queryPostsInThread} from '@queries/servers/post';
1719
import {queryDisplayNamePreferences} from '@queries/servers/preference';
1820
import {prepareCommonSystemValues, type PrepareCommonSystemValuesArgs, getCommonSystemValues, getCurrentTeamId, setCurrentChannelId, getCurrentUserId, getConfig, getLicense} from '@queries/servers/system';
1921
import {addChannelToTeamHistory, addTeamToTeamHistory, getTeamById, removeChannelFromTeamHistory} from '@queries/servers/team';
@@ -296,9 +298,15 @@ export async function updateMyChannelFromWebsocket(serverUrl: string, channelMem
296298
try {
297299
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
298300
const member = await getMyChannel(database, channelMember.channel_id);
301+
302+
if (member && Boolean(member.autotranslationDisabled) !== Boolean(channelMember.autotranslation_disabled)) {
303+
await deletePostsForChannel(serverUrl, channelMember.channel_id);
304+
}
305+
299306
if (member) {
300307
member.prepareUpdate((m) => {
301308
m.roles = channelMember.roles;
309+
m.autotranslationDisabled = channelMember.autotranslation_disabled ?? false;
302310
});
303311
if (!prepareRecordsOnly) {
304312
operator.batchRecords([member], 'updateMyChannelFromWebsocket');
@@ -485,3 +493,86 @@ export const updateDmGmDisplayName = async (serverUrl: string) => {
485493
return {error};
486494
}
487495
};
496+
497+
export async function deletePostsForChannel(serverUrl: string, channelId: string, prepareRecordsOnly = false): Promise<{models: Model[]; error?: unknown}> {
498+
try {
499+
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
500+
const channel = await getChannelById(database, channelId);
501+
502+
if (!channel) {
503+
return {models: []};
504+
}
505+
506+
const posts = await channel.posts.fetch();
507+
if (!posts.length) {
508+
return {models: []};
509+
}
510+
511+
const preparedPostsPromises = posts.map((post) => prepareDeletePost(post));
512+
const preparedPostsArrays = await Promise.all(preparedPostsPromises);
513+
const preparedModels: Model[] = preparedPostsArrays.flat();
514+
515+
const postsInChannel = await queryPostsInChannel(database, channelId);
516+
if (postsInChannel.length) {
517+
for (const postRange of postsInChannel) {
518+
const preparedPostRanges = postRange.prepareDestroyPermanently();
519+
preparedModels.push(preparedPostRanges);
520+
}
521+
}
522+
523+
const threadPromises = posts.filter((post) => post.rootId === '').map((post) => {
524+
return queryPostsInThread(database, post.id).fetch();
525+
});
526+
527+
const threadRanges = (await Promise.all(threadPromises)).flat();
528+
for (const threadRange of threadRanges) {
529+
const preparedThreadRange = threadRange.prepareDestroyPermanently();
530+
preparedModels.push(preparedThreadRange);
531+
}
532+
533+
const myChannel = await getMyChannel(database, channelId);
534+
if (myChannel) {
535+
myChannel.prepareUpdate((v) => {
536+
v.lastFetchedAt = 0;
537+
});
538+
preparedModels.push(myChannel);
539+
}
540+
541+
if (preparedModels.length && !prepareRecordsOnly) {
542+
await operator.batchRecords(preparedModels, 'deletePostsForChannel');
543+
DeviceEventEmitter.emit(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId});
544+
}
545+
546+
return {error: false, models: preparedModels};
547+
} catch (error) {
548+
logError('Failed deletePostsForChannel', error);
549+
return {error, models: []};
550+
}
551+
}
552+
553+
export async function deletePostsForChannelsWithAutotranslation(serverUrl: string, prepareRecordsOnly = false) {
554+
try {
555+
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
556+
const myChannels = await queryMyChannelsWithAutotranslation(database).fetch();
557+
558+
const deleteResults = await Promise.all(
559+
myChannels.map((myChannel) => deletePostsForChannel(serverUrl, myChannel.id, prepareRecordsOnly)),
560+
);
561+
562+
const allModels: Model[] = [];
563+
for (const result of deleteResults) {
564+
if (result.models) {
565+
allModels.push(...result.models);
566+
}
567+
}
568+
569+
if (allModels.length && !prepareRecordsOnly) {
570+
await operator.batchRecords(allModels, 'deletePostsForChannelsWithAutotranslation');
571+
}
572+
573+
return {error: undefined, models: allModels};
574+
} catch (error) {
575+
logError('Failed deletePostsForChannelsWithAutotranslation', error);
576+
return {error, models: []};
577+
}
578+
}

0 commit comments

Comments
 (0)