diff --git a/CLAUDE.md b/CLAUDE.md index 93c96b595..b44550798 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,6 +143,8 @@ Located at `libraries/@mattermost/`: ## Testing +**Testing guide:** See [docs/testing_guide.md](docs/testing_guide.md) for how to add and structure unit tests. + ### Test Organization - **Jest coverage excludes** `/components/` and `/screens/` directories - Mock database manager at `app/database/manager/__mocks__/index.ts` diff --git a/app/actions/local/channel.test.ts b/app/actions/local/channel.test.ts index 409daf455..07faf3989 100644 --- a/app/actions/local/channel.test.ts +++ b/app/actions/local/channel.test.ts @@ -5,13 +5,15 @@ import {DeviceEventEmitter} from 'react-native'; -import {Navigation} from '@constants'; +import {ActionType, Events, Navigation} from '@constants'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import {getMyChannel} from '@queries/servers/channel'; +import {getPostById} from '@queries/servers/post'; import {getCommonSystemValues, getTeamHistory} from '@queries/servers/system'; import {getTeamChannelHistory} from '@queries/servers/team'; import {dismissAllModalsAndPopToRoot, dismissAllModalsAndPopToScreen} from '@screens/navigation'; +import TestHelper from '@test/test_helper'; import { switchToChannel, @@ -27,6 +29,8 @@ import { updateChannelsDisplayName, showUnreadChannelsOnly, updateDmGmDisplayName, + deletePostsForChannelsWithAutotranslation, + deletePostsForChannel, } from './channel'; import type {ChannelModel, MyChannelModel, SystemModel} from '@database/models/server'; @@ -770,19 +774,19 @@ describe('updateMyChannelFromWebsocket', () => { const serverUrl = 'baseHandler.test.com'; const channelId = 'id1'; const teamId = 'tId1'; - const channel: Channel = { + const channel: Channel = TestHelper.fakeChannel({ id: channelId, team_id: teamId, total_msg_count: 0, delete_at: 0, - } as Channel; - const channelMember: ChannelMembership = { + }); + const channelMember: ChannelMembership = TestHelper.fakeChannelMember({ id: 'id', user_id: 'userid', channel_id: channelId, msg_count: 0, roles: '', - } as ChannelMembership; + }); beforeEach(async () => { await DatabaseManager.init([serverUrl]); @@ -808,6 +812,27 @@ describe('updateMyChannelFromWebsocket', () => { expect(model).toBeDefined(); expect(model?.roles).toBe('channel_user'); }); + + it('calls deletePostsForChannel when autotranslation changes', async () => { + const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''}); + await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false}); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + await operator.handlePosts({actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, order: [post.id], posts: [post], prepareRecordsOnly: false}); + + await updateMyChannelFromWebsocket(serverUrl, {...channelMember, autotranslation_disabled: true}, false); + + expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId}); + }); + + it('updates member autotranslation from channelMember', async () => { + await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false}); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + + await updateMyChannelFromWebsocket(serverUrl, {...channelMember, autotranslation_disabled: true}, false); + + const member = await getMyChannel(operator.database, channelId); + expect(member?.autotranslationDisabled).toBe(true); + }); }); describe('updateChannelInfoFromChannel', () => { @@ -1133,3 +1158,186 @@ describe('updateDmGmDisplayName', () => { expect((channels![1] as ChannelModel).displayName).toBe(`${user2.username}, ${user3.username}`); }); }); + +describe('deletePostsForChannel', () => { + const serverUrl = 'baseHandler.test.com'; + let operator: ServerDataOperator; + + const channelId = 'channelid1'; + const teamId = 'tId1'; + const channel: Channel = TestHelper.fakeChannel({ + id: channelId, + team_id: teamId, + total_msg_count: 0, + }); + const channelMember: ChannelMembership = TestHelper.fakeChannelMember({ + id: 'id', + channel_id: channelId, + msg_count: 0, + }); + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + operator = DatabaseManager.serverDatabases[serverUrl]!.operator; + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + it('handle not found database', async () => { + const {models, error} = await deletePostsForChannel('foo', channelId); + expect(models).toEqual([]); + expect(error).toBeTruthy(); + }); + + it('channel not found', async () => { + const {models, error} = await deletePostsForChannel(serverUrl, 'nonexistent'); + expect(models).toEqual([]); + expect(error).toBeFalsy(); + }); + + it('channel with no posts', async () => { + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false}); + + const {models, error} = await deletePostsForChannel(serverUrl, channelId); + expect(models).toEqual([]); + expect(error).toBeFalsy(); + }); + + it('channel with posts - batch written and event emitted', async () => { + const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''}); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false}); + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [post.id], + posts: [post], + prepareRecordsOnly: false, + }); + + const listener = jest.fn(); + const subscription = DeviceEventEmitter.addListener(Events.POST_DELETED_FOR_CHANNEL, listener); + + const {models, error} = await deletePostsForChannel(serverUrl, channelId); + subscription.remove(); + + expect(error).toBeFalsy(); + expect(models.length).toBeGreaterThan(0); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({serverUrl, channelId}); + + const myChannel = await getMyChannel(operator.database, channelId); + expect(myChannel?.lastFetchedAt).toBe(0); + }); + + it('prepareRecordsOnly true - no write and no emit', async () => { + const post = TestHelper.fakePost({id: 'postid2', channel_id: channelId, root_id: ''}); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false}); + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [post.id], + posts: [post], + prepareRecordsOnly: false, + }); + + const listener = jest.fn(); + const subscription = DeviceEventEmitter.addListener(Events.POST_DELETED_FOR_CHANNEL, listener); + + const {models, error} = await deletePostsForChannel(serverUrl, channelId, true); + subscription.remove(); + + expect(error).toBeFalsy(); + expect(models.length).toBeGreaterThan(0); + expect(listener).not.toHaveBeenCalled(); + + const myChannel = await getMyChannel(operator.database, channelId); + expect(myChannel?._preparedState).toBe('update'); + + // Batch the records to avoid the invariant error + await operator.batchRecords([...models], 'test'); + }); +}); + +describe('deletePostsForChannelsWithAutotranslation', () => { + const serverUrl = 'baseHandler.test.com'; + let operator: ServerDataOperator; + + const channelId = 'channelid1'; + const teamId = 'tId1'; + const channel: Channel = TestHelper.fakeChannel({ + id: channelId, + team_id: teamId, + total_msg_count: 0, + autotranslation: true, + }); + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + operator = DatabaseManager.serverDatabases[serverUrl]!.operator; + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + it('no myChannels with autotranslation - no deletes', async () => { + const channelMember: ChannelMembership = TestHelper.fakeChannelMember({ + id: 'id', + channel_id: channelId, + msg_count: 0, + autotranslation_disabled: true, + }); + const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''}); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false}); + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [post.id], + posts: [post], + prepareRecordsOnly: false, + }); + + const {models, error} = await deletePostsForChannelsWithAutotranslation(serverUrl); + expect(error).toBeUndefined(); + expect(models).toEqual([]); + + const databasePost = await getPostById(operator.database, post.id); + expect(databasePost).toBeDefined(); + }); + + it('myChannels with autotranslation - delete posts', async () => { + const channelMember: ChannelMembership = TestHelper.fakeChannelMember({ + id: 'id', + channel_id: channelId, + msg_count: 0, + autotranslation_disabled: false, + }); + const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''}); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false}); + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [post.id], + posts: [post], + prepareRecordsOnly: false, + }); + + const {models, error} = await deletePostsForChannelsWithAutotranslation(serverUrl); + expect(error).toBeUndefined(); + expect(models.length).toBeGreaterThan(0); + const databasePost = await getPostById(operator.database, post.id); + expect(databasePost).toBeUndefined(); + }); + + it('handle error', async () => { + jest.spyOn(DatabaseManager, 'getServerDatabaseAndOperator').mockImplementationOnce(() => { + throw new Error('DB error'); + }); + const {models, error} = await deletePostsForChannelsWithAutotranslation(serverUrl); + expect(models).toEqual([]); + expect(error).toBeTruthy(); + }); +}); diff --git a/app/actions/local/channel.ts b/app/actions/local/channel.ts index 7d2db49a7..9317d3ec5 100644 --- a/app/actions/local/channel.ts +++ b/app/actions/local/channel.ts @@ -3,7 +3,7 @@ import {DeviceEventEmitter} from 'react-native'; -import {General, Navigation as NavigationConstants, Preferences, Screens} from '@constants'; +import {Events, General, Navigation as NavigationConstants, Preferences, Screens} from '@constants'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import {getTeammateNameDisplaySetting} from '@helpers/api/preference'; @@ -13,7 +13,9 @@ import { prepareDeleteChannel, prepareMyChannelsForTeam, queryAllMyChannel, getMyChannel, getChannelById, queryUsersOnChannel, queryUserChannelsByTypes, prepareAllMyChannels, + queryMyChannelsWithAutotranslation, } from '@queries/servers/channel'; +import {prepareDeletePost, queryPostsInChannel, queryPostsInThread} from '@queries/servers/post'; import {queryDisplayNamePreferences} from '@queries/servers/preference'; import {prepareCommonSystemValues, type PrepareCommonSystemValuesArgs, getCommonSystemValues, getCurrentTeamId, setCurrentChannelId, getCurrentUserId, getConfig, getLicense} from '@queries/servers/system'; import {addChannelToTeamHistory, addTeamToTeamHistory, getTeamById, removeChannelFromTeamHistory} from '@queries/servers/team'; @@ -296,9 +298,15 @@ export async function updateMyChannelFromWebsocket(serverUrl: string, channelMem try { const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const member = await getMyChannel(database, channelMember.channel_id); + + if (member && Boolean(member.autotranslationDisabled) !== Boolean(channelMember.autotranslation_disabled)) { + await deletePostsForChannel(serverUrl, channelMember.channel_id); + } + if (member) { member.prepareUpdate((m) => { m.roles = channelMember.roles; + m.autotranslationDisabled = channelMember.autotranslation_disabled ?? false; }); if (!prepareRecordsOnly) { operator.batchRecords([member], 'updateMyChannelFromWebsocket'); @@ -485,3 +493,86 @@ export const updateDmGmDisplayName = async (serverUrl: string) => { return {error}; } }; + +export async function deletePostsForChannel(serverUrl: string, channelId: string, prepareRecordsOnly = false): Promise<{models: Model[]; error?: unknown}> { + try { + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const channel = await getChannelById(database, channelId); + + if (!channel) { + return {models: []}; + } + + const posts = await channel.posts.fetch(); + if (!posts.length) { + return {models: []}; + } + + const preparedPostsPromises = posts.map((post) => prepareDeletePost(post)); + const preparedPostsArrays = await Promise.all(preparedPostsPromises); + const preparedModels: Model[] = preparedPostsArrays.flat(); + + const postsInChannel = await queryPostsInChannel(database, channelId); + if (postsInChannel.length) { + for (const postRange of postsInChannel) { + const preparedPostRanges = postRange.prepareDestroyPermanently(); + preparedModels.push(preparedPostRanges); + } + } + + const threadPromises = posts.filter((post) => post.rootId === '').map((post) => { + return queryPostsInThread(database, post.id).fetch(); + }); + + const threadRanges = (await Promise.all(threadPromises)).flat(); + for (const threadRange of threadRanges) { + const preparedThreadRange = threadRange.prepareDestroyPermanently(); + preparedModels.push(preparedThreadRange); + } + + const myChannel = await getMyChannel(database, channelId); + if (myChannel) { + myChannel.prepareUpdate((v) => { + v.lastFetchedAt = 0; + }); + preparedModels.push(myChannel); + } + + if (preparedModels.length && !prepareRecordsOnly) { + await operator.batchRecords(preparedModels, 'deletePostsForChannel'); + DeviceEventEmitter.emit(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId}); + } + + return {error: false, models: preparedModels}; + } catch (error) { + logError('Failed deletePostsForChannel', error); + return {error, models: []}; + } +} + +export async function deletePostsForChannelsWithAutotranslation(serverUrl: string, prepareRecordsOnly = false) { + try { + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const myChannels = await queryMyChannelsWithAutotranslation(database).fetch(); + + const deleteResults = await Promise.all( + myChannels.map((myChannel) => deletePostsForChannel(serverUrl, myChannel.id, prepareRecordsOnly)), + ); + + const allModels: Model[] = []; + for (const result of deleteResults) { + if (result.models) { + allModels.push(...result.models); + } + } + + if (allModels.length && !prepareRecordsOnly) { + await operator.batchRecords(allModels, 'deletePostsForChannelsWithAutotranslation'); + } + + return {error: undefined, models: allModels}; + } catch (error) { + logError('Failed deletePostsForChannelsWithAutotranslation', error); + return {error, models: []}; + } +} diff --git a/app/actions/local/post.test.ts b/app/actions/local/post.test.ts index 0b9be27d2..80aebfa71 100644 --- a/app/actions/local/post.test.ts +++ b/app/actions/local/post.test.ts @@ -4,6 +4,7 @@ import {ActionType, Post} from '@constants'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; +import {getPostById} from '@queries/servers/post'; import TestHelper from '@test/test_helper'; import {COMBINED_USER_ACTIVITY} from '@utils/post_list'; @@ -18,6 +19,7 @@ import { removePostAcknowledgement, deletePosts, getUsersCountFromMentions, + updatePostTranslation, } from './post'; import type ServerDataOperator from '@database/operator/server_data_operator'; @@ -352,3 +354,49 @@ describe('getUsersCountFromMentions', () => { expect(num).toBe(1); }); }); + +describe('updatePostTranslation', () => { + const post = TestHelper.fakePost({id: 'postid4', channel_id: channelId}); + + it('post not found', async () => { + const translation: PostTranslation = {object: {message: 'Hola'}, state: 'ready', source_lang: 'en'}; + const result = await updatePostTranslation(serverUrl, 'nonexistent', 'es', translation); + expect(result.error).toBe('Post not found'); + }); + + it('updates post metadata with translation', async () => { + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [post.id], + posts: [post], + prepareRecordsOnly: false, + }); + + const translation: PostTranslation = {object: {message: 'Hola'}, state: 'ready', source_lang: 'en'}; + const result = await updatePostTranslation(serverUrl, post.id, 'es', translation); + expect(result.error).toBeUndefined(); + + const updatedPost = await getPostById(operator.database, post.id); + expect(updatedPost).toBeDefined(); + expect(updatedPost?.metadata?.translations?.es).toEqual(translation); + }); + + it('handle database write error', async () => { + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [post.id], + posts: [post], + prepareRecordsOnly: false, + }); + + const originalWrite = operator.database.write; + operator.database.write = jest.fn().mockRejectedValue(new Error('Write failed')); + + const translation: PostTranslation = {object: {message: 'Hola'}, state: 'ready', source_lang: 'en'}; + const result = await updatePostTranslation(serverUrl, post.id, 'es', translation); + + operator.database.write = originalWrite; + + expect(result.error).toBeTruthy(); + }); +}); diff --git a/app/actions/local/post.ts b/app/actions/local/post.ts index 803be3d9d..c313b53aa 100644 --- a/app/actions/local/post.ts +++ b/app/actions/local/post.ts @@ -9,6 +9,7 @@ import {countUsersFromMentions, getPostById, prepareDeletePost, queryPostsById} import {getCurrentUserId} from '@queries/servers/system'; import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers/thread'; import {generateId} from '@utils/general'; +import {safeParseJSON} from '@utils/helpers'; import {logError} from '@utils/log'; import {getLastFetchedAtFromPosts} from '@utils/post'; import {getPostIdsForCombinedUserActivityPost} from '@utils/post_list'; @@ -390,3 +391,31 @@ export function getUsersCountFromMentions(serverUrl: string, mentions: string[]) return Promise.resolve(0); } } + +export const updatePostTranslation = async (serverUrl: string, postId: string, language: string, translation: PostTranslation) => { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const post = await getPostById(database, postId); + if (!post) { + return {error: 'Post not found'}; + } + await database.write(async () => { + post.update((v) => { + // At this point, the metadata is a string, so we need to parse it + let metadata: PostMetadata = safeParseJSON(v.metadata as string) as PostMetadata; + if (!metadata) { + metadata = {}; + } + if (!metadata.translations) { + metadata.translations = {}; + } + metadata.translations[language] = translation; + v.metadata = JSON.stringify(metadata) as any; + }); + }); + return {error: undefined}; + } catch (error) { + logError('Failed updatePostTranslation', error); + return {error}; + } +}; diff --git a/app/actions/local/systems.ts b/app/actions/local/systems.ts index b790f3b02..f91ee8c21 100644 --- a/app/actions/local/systems.ts +++ b/app/actions/local/systems.ts @@ -25,6 +25,7 @@ import PostModel from '@typings/database/models/servers/post'; import {isExpiredBoRPost} from '@utils/bor'; import {logError} from '@utils/log'; +import {deletePostsForChannelsWithAutotranslation} from './channel'; import {deletePosts} from './post'; import type {DataRetentionPoliciesRequest} from '@actions/remote/systems'; @@ -71,6 +72,9 @@ export async function storeConfig(serverUrl: string, config: ClientConfig | unde const configsToUpdate: IdValue[] = []; const configsToDelete: IdValue[] = []; + // Check if EnableAutoTranslation changed from enabled to disabled + const enableAutoTranslationChanged = (currentConfig?.EnableAutoTranslation === 'true') !== (config.EnableAutoTranslation === 'true'); + let k: keyof ClientConfig; for (k in config) { if (currentConfig?.[k] !== config[k]) { @@ -92,6 +96,12 @@ export async function storeConfig(serverUrl: string, config: ClientConfig | unde if (configsToDelete.length || configsToUpdate.length) { const results = await operator.handleConfigs({configs: configsToUpdate, configsToDelete, prepareRecordsOnly}); DeviceEventEmitter.emit(Events.CONFIG_CHANGED, {serverUrl, config}); + + // If EnableAutoTranslation was disabled, delete posts and disable user autotranslation + if (enableAutoTranslationChanged) { + await deletePostsForChannelsWithAutotranslation(serverUrl, prepareRecordsOnly); + } + return results; } } catch (error) { diff --git a/app/actions/remote/channel.test.ts b/app/actions/remote/channel.test.ts index c686af023..fee3127e0 100644 --- a/app/actions/remote/channel.test.ts +++ b/app/actions/remote/channel.test.ts @@ -4,11 +4,13 @@ /* eslint-disable max-lines */ import {createIntl} from 'react-intl'; +import {DeviceEventEmitter} from 'react-native'; -import {DeepLink} from '@constants'; +import {ActionType, DeepLink, Events} from '@constants'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import NetworkManager from '@managers/network_manager'; +import TestHelper from '@test/test_helper'; import { removeMemberFromChannel, @@ -55,6 +57,8 @@ import { handleKickFromChannel, fetchGroupMessageMembersCommonTeams, convertGroupMessageToPrivateChannel, + setChannelAutotranslation, + setMyChannelAutotranslation, } from './channel'; import type ServerDataOperator from '@database/operator/server_data_operator'; @@ -134,6 +138,8 @@ const mockClient = { posts: [], previousPostId: '', })), + setChannelAutotranslation: jest.fn(), + setMyChannelAutotranslation: jest.fn(), }; const teamId = 'teamid1'; @@ -145,7 +151,6 @@ const intl = createIntl({ }); beforeAll(() => { - // eslint-disable-next-line // @ts-ignore NetworkManager.getClient = () => mockClient; }); @@ -765,3 +770,59 @@ describe('direct and group', () => { expect(updatedChannel).toBeDefined(); }); }); + +describe('setChannelAutotranslation', () => { + it('should update channel and call deletePostsForChannel when disabling autotranslation', async () => { + const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''}); + const channelWithAutotranslation = TestHelper.fakeChannel({id: channelId, team_id: teamId, autotranslation: true}); + await operator.handleChannel({channels: [channelWithAutotranslation], prepareRecordsOnly: false}); + await operator.handlePosts({actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, order: [post.id], posts: [post], prepareRecordsOnly: false}); + jest.mocked(mockClient.setChannelAutotranslation).mockResolvedValue({id: channelId, autotranslation: false} as Channel); + + const result = await setChannelAutotranslation(serverUrl, channelId, false); + + expect(result.error).toBeUndefined(); + expect(result.channel).toBeDefined(); + expect(result.channel!.autotranslation).toBe(false); + expect(mockClient.setChannelAutotranslation).toHaveBeenCalledWith(channelId, false); + + expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId}); + }); + + it('should handle client error', async () => { + jest.mocked(mockClient.setChannelAutotranslation).mockRejectedValueOnce(new Error('API error')); + + const result = await setChannelAutotranslation(serverUrl, channelId, true); + + expect(result.error).toBeDefined(); + expect(DeviceEventEmitter.emit).not.toHaveBeenCalled(); + }); +}); + +describe('setMyChannelAutotranslation', () => { + it('should update myChannel and call deletePostsForChannel when autotranslation changes', async () => { + const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''}); + const channel = TestHelper.fakeChannel({id: channelId, team_id: teamId}); + const myChannelMember = TestHelper.fakeChannelMember({channel_id: channelId, autotranslation_disabled: false}); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + await operator.handleMyChannel({channels: [channel], myChannels: [myChannelMember], prepareRecordsOnly: false}); + await operator.handlePosts({actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, order: [post.id], posts: [post], prepareRecordsOnly: false}); + jest.mocked(mockClient.setMyChannelAutotranslation).mockResolvedValue({} as ChannelMembership); + + const result = await setMyChannelAutotranslation(serverUrl, channelId, false); + + 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}); + }); + + it('should handle client error', async () => { + jest.mocked(mockClient.setMyChannelAutotranslation).mockRejectedValueOnce(new Error('API error')); + + const result = await setMyChannelAutotranslation(serverUrl, channelId, true); + + expect(result.error).toBeDefined(); + expect(DeviceEventEmitter.emit).not.toHaveBeenCalled(); + }); +}); diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 1b6ecd756..6e77e27fc 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -5,7 +5,7 @@ import {DeviceEventEmitter} from 'react-native'; import {addChannelToDefaultCategory, handleConvertedGMCategories, storeCategories} from '@actions/local/category'; -import {markChannelAsViewed, removeCurrentUserFromChannel, setChannelDeleteAt, storeAllMyChannels, storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel'; +import {markChannelAsViewed, removeCurrentUserFromChannel, setChannelDeleteAt, storeAllMyChannels, storeMyChannelsForTeam, switchToChannel, deletePostsForChannel} from '@actions/local/channel'; import {switchToGlobalDrafts} from '@actions/local/draft'; import {switchToGlobalThreads} from '@actions/local/thread'; import {loadCallForChannel} from '@calls/actions/calls'; @@ -272,18 +272,21 @@ export async function patchChannel(serverUrl: string, channelId: string, channel } const channel = await getChannelById(database, channelData.id); - if (channel && (channel.displayName !== channelData.display_name || channel.type !== channelData.type)) { + if (channel && (channel.displayName !== channelData.display_name || channel.type !== channelData.type || channel.autotranslation !== channelData.autotranslation)) { channel.prepareUpdate((v) => { // DM and GM display names cannot be patched and are formatted client-side; do not overwrite if (channelData.type !== General.DM_CHANNEL && channelData.type !== General.GM_CHANNEL) { v.displayName = channelData.display_name; } v.type = channelData.type; + if (channelData.autotranslation !== undefined) { + v.autotranslation = channelData.autotranslation; + } }); models.push(channel); } if (models?.length) { - await operator.batchRecords(models.flat(), 'patchChannel'); + await operator.batchRecords(models, 'patchChannel'); } return {channel: channelData}; } catch (error) { @@ -1243,6 +1246,76 @@ export const updateChannelNotifyProps = async (serverUrl: string, channelId: str } }; +export const setChannelAutotranslation = async (serverUrl: string, channelId: string, enabled: boolean) => { + try { + const client = NetworkManager.getClient(serverUrl); + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + + const channelData = await client.setChannelAutotranslation(channelId, enabled); + const models = []; + + const channel = await getChannelById(database, channelData.id); + const autotranslationDisabled = channel && channel.autotranslation && !channelData.autotranslation; + + if (channel && channel.autotranslation !== channelData.autotranslation) { + channel.prepareUpdate((v) => { + v.autotranslation = channelData.autotranslation ?? false; + }); + models.push(channel); + } + + if (models?.length) { + await operator.batchRecords(models, 'setChannelAutotranslation'); + } + + // Delete posts when autotranslation setting changes + if (autotranslationDisabled) { + await deletePostsForChannel(serverUrl, channelData.id); + } + + return {channel: channelData}; + } catch (error) { + logDebug('error on setChannelAutotranslation', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +}; + +export const setMyChannelAutotranslation = async (serverUrl: string, channelId: string, enabled: boolean) => { + try { + const client = NetworkManager.getClient(serverUrl); + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + + await client.setMyChannelAutotranslation(channelId, enabled); + const models = []; + + const myChannel = await getMyChannel(database, channelId); + const autotranslationChanged = myChannel && myChannel.autotranslationDisabled !== !enabled; + + if (myChannel && autotranslationChanged) { + myChannel.prepareUpdate((v) => { + v.autotranslationDisabled = !enabled; + }); + models.push(myChannel); + } + + if (models?.length) { + await operator.batchRecords(models, 'setMyChannelAutotranslation'); + } + + // Delete posts when autotranslation setting changes + if (autotranslationChanged) { + await deletePostsForChannel(serverUrl, channelId); + } + + return {data: true}; + } catch (error) { + logDebug('error on setMyChannelAutotranslation', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +}; + export const toggleMuteChannel = async (serverUrl: string, channelId: string, showSnackBar = false) => { try { const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); diff --git a/app/actions/remote/entry/common.ts b/app/actions/remote/entry/common.ts index 3931f7aac..21e164b24 100644 --- a/app/actions/remote/entry/common.ts +++ b/app/actions/remote/entry/common.ts @@ -4,6 +4,7 @@ import {nativeApplicationVersion} from 'expo-application'; import {RESULTS, checkNotifications} from 'react-native-permissions'; +import {deletePostsForChannel, deletePostsForChannelsWithAutotranslation} from '@actions/local/channel'; import {fetchChannelById, fetchMyChannelsForTeam, handleKickFromChannel, type MyChannelsRequest} from '@actions/remote/channel'; import {type MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference'; import {fetchConfigAndLicense, fetchDataRetentionPolicy} from '@actions/remote/systems'; @@ -18,11 +19,12 @@ import {selectDefaultTeam} from '@helpers/api/team'; import {DEFAULT_LOCALE} from '@i18n'; import NetworkManager from '@managers/network_manager'; import {getDeviceToken} from '@queries/app/global'; -import {getChannelById} from '@queries/servers/channel'; +import {getChannelById, queryChannelsById, queryMyChannelsByChannelIds} from '@queries/servers/channel'; import {prepareEntryModels, truncateCrtRelatedTables} from '@queries/servers/entry'; import {getHasCRTChanged} from '@queries/servers/preference'; import {getCurrentChannelId, getCurrentTeamId, getIsDataRetentionEnabled, getPushVerificationStatus, getLastFullSync, setCurrentTeamAndChannelId, getConfigValue} from '@queries/servers/system'; import {getTeamChannelHistory} from '@queries/servers/team'; +import {getCurrentUser} from '@queries/servers/user'; import NavigationStore from '@store/navigation_store'; import {isDefaultChannel, isDMorGM, sortChannelsByDisplayName} from '@utils/channel'; import {getFullErrorMessage} from '@utils/errors'; @@ -179,6 +181,8 @@ const entryRest = async (serverUrl: string, teamId?: string, channelId?: string, const dt = Date.now(); + await handleAutotranslationChanges(serverUrl, meData, chData); + const modelPromises = await prepareEntryModels({operator, teamData: initialTeamData, chData, prefData, meData, isCRTEnabled}); const models = (await Promise.all(modelPromises)).flat(); logDebug('Process models on entry', groupLabel, models.length, `${Date.now() - dt}ms`); @@ -190,6 +194,65 @@ const entryRest = async (serverUrl: string, teamId?: string, channelId?: string, } }; +export async function handleAutotranslationChanges(serverUrl: string, meData: MyUserRequest | undefined, chData: MyChannelsRequest | undefined) { + try { + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + + // Config changes already handled in storeConfigAndLicense + const enableAutoTranslation = (await getConfigValue(operator.database, 'EnableAutoTranslation')) === 'true'; + if (!enableAutoTranslation) { + return; + } + + // If user locale changed, delete all the posts for all channels with autotranslation enabled + // so the posts can be refetch with the new translation + if (meData) { + const currentUser = await getCurrentUser(database); + if (currentUser && meData.user?.locale !== currentUser.locale) { + await deletePostsForChannelsWithAutotranslation(serverUrl); + return; + } + } + + if (chData) { + // If a channel stop being autotranslated by the admin, delete the posts for that channel + // so the posts can be refetch without the translation + const newChannels = chData.channels || []; + const channels = await queryChannelsById(database, newChannels.map((c) => c.id)).fetch(); + const chMap = new Map(channels.map((c) => [c.id, c])); + const promises = []; + for (const ch of newChannels) { + const channel = chMap.get(ch.id); + if (channel && channel.autotranslation && !ch.autotranslation) { // Autotranslation disabled + promises.push(deletePostsForChannel(serverUrl, ch.id, true)); + } + } + const chModels = (await Promise.all(promises)).map((m) => m.models).flat(); + if (chModels.length) { + await operator.batchRecords(chModels, 'handleAutotranslationChanges'); + } + + // If a channel starts or stops being autotranslated by the user, delete the posts for that channel + // so the posts can be refetch with or without the translation + const newMemberships = chData.memberships || []; + const memberships = await queryMyChannelsByChannelIds(database, newMemberships.map((m) => m.channel_id)).fetch(); + const membershipMap = new Map(memberships.map((m) => [m.id, m])); + for (const m of newMemberships) { + const membership = membershipMap.get(m.channel_id); + if (membership && membership.autotranslationDisabled !== Boolean(m.autotranslation_disabled)) { // Autotranslation modified + promises.push(deletePostsForChannel(serverUrl, m.channel_id, true)); + } + } + const membershipModels = (await Promise.all(promises)).map((m) => m.models).flat(); + if (membershipModels.length) { + await operator.batchRecords(membershipModels, 'handleAutotranslationChanges'); + } + } + } catch (error) { + logError('handleAutotranslationChanges', getFullErrorMessage(error)); + } +} + export async function entryInitialChannelId(database: Database, requestedChannelId = '', requestedTeamId = '', initialTeamId: string, locale: string, channels?: Channel[], memberships?: ChannelMember[]) { const membershipIds = new Set(memberships?.map((m) => m.channel_id)); const requestedChannel = channels?.find((c) => (c.id === requestedChannelId) && membershipIds.has(c.id)); diff --git a/app/actions/remote/entry/deferred.test.ts b/app/actions/remote/entry/deferred.test.ts index aed41ddcf..69b173edf 100644 --- a/app/actions/remote/entry/deferred.test.ts +++ b/app/actions/remote/entry/deferred.test.ts @@ -335,7 +335,7 @@ describe('actions/remote/entry/deferred', () => { ); }); - expect(processEntryModels).toHaveBeenCalledWith( + expect(processEntryModels).toHaveBeenCalledWith(serverUrl, expect.objectContaining({ teamData: expect.objectContaining({ teams: expect.arrayContaining([ @@ -345,7 +345,7 @@ describe('actions/remote/entry/deferred', () => { }), ); - expect(processEntryModels).toHaveBeenCalledWith( + expect(processEntryModels).toHaveBeenCalledWith(serverUrl, expect.objectContaining({ teamData: expect.objectContaining({ teams: expect.arrayContaining([ diff --git a/app/actions/remote/entry/deferred.ts b/app/actions/remote/entry/deferred.ts index a9ed8a8dc..25694ff30 100644 --- a/app/actions/remote/entry/deferred.ts +++ b/app/actions/remote/entry/deferred.ts @@ -102,7 +102,7 @@ export async function restDeferredAppEntryActions( }; /* eslint-disable-next-line no-await-in-loop */ - await processEntryModels({operator, teamData: currentTeamData, chData: data, isCRTEnabled}); + await processEntryModels(serverUrl, {operator, teamData: currentTeamData, chData: data, isCRTEnabled}); } const uniqueChannelsData: MyChannelsRequest = { diff --git a/app/actions/remote/user.ts b/app/actions/remote/user.ts index 9d984c129..4a4ec0fdb 100644 --- a/app/actions/remote/user.ts +++ b/app/actions/remote/user.ts @@ -5,7 +5,7 @@ import {chunk} from 'lodash'; -import {updateChannelsDisplayName} from '@actions/local/channel'; +import {updateChannelsDisplayName, deletePostsForChannelsWithAutotranslation} from '@actions/local/channel'; import {updateRecentCustomStatuses, updateLocalUser} from '@actions/local/user'; import {fetchRolesIfNeeded} from '@actions/remote/role'; import {General} from '@constants'; @@ -15,7 +15,7 @@ import NetworkManager from '@managers/network_manager'; import {getMembersCountByChannelsId, queryChannelsByTypes} from '@queries/servers/channel'; import {queryGroupsByNames} from '@queries/servers/group'; import {getCurrentUserId, setCurrentUserId} from '@queries/servers/system'; -import {getCurrentUser, prepareUsers, queryAllUsers, queryUsersById, queryUsersByIdsOrUsernames, queryUsersByUsername} from '@queries/servers/user'; +import {getCurrentUser, getUserById, prepareUsers, queryAllUsers, queryUsersById, queryUsersByIdsOrUsernames, queryUsersByUsername} from '@queries/servers/user'; import {getFullErrorMessage} from '@utils/errors'; import {logDebug} from '@utils/log'; import {getDeviceTimezone} from '@utils/timezone'; @@ -47,7 +47,7 @@ export type ProfilesInChannelRequest = { export const fetchMe = async (serverUrl: string, fetchOnly = false, groupLabel?: RequestGroupLabel): Promise => { try { const client = NetworkManager.getClient(serverUrl); - const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const resultSettled = await Promise.allSettled([client.getMe(groupLabel), client.getStatus('me', groupLabel)]); let user: UserProfile|undefined; @@ -71,6 +71,14 @@ export const fetchMe = async (serverUrl: string, fetchOnly = false, groupLabel?: if (!fetchOnly) { await operator.handleUsers({users: [user], prepareRecordsOnly: false}); + + const currentUser = await getUserById(database, user.id); + if (currentUser) { + const localeChanged = currentUser.locale !== user.locale; + if (localeChanged) { + await deletePostsForChannelsWithAutotranslation(serverUrl); + } + } } return {user}; diff --git a/app/actions/websocket/channel.test.ts b/app/actions/websocket/channel.test.ts index 88d9cdf93..5572c4427 100644 --- a/app/actions/websocket/channel.test.ts +++ b/app/actions/websocket/channel.test.ts @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import {addChannelToDefaultCategory, handleConvertedGMCategories} from '@actions/local/category'; -import {markChannelAsViewed, removeCurrentUserFromChannel, setChannelDeleteAt, storeMyChannelsForTeam, updateChannelInfoFromChannel, updateMyChannelFromWebsocket} from '@actions/local/channel'; +import {markChannelAsViewed, removeCurrentUserFromChannel, setChannelDeleteAt, storeMyChannelsForTeam, updateChannelInfoFromChannel, updateMyChannelFromWebsocket, deletePostsForChannel} from '@actions/local/channel'; import {storePostsForChannel} from '@actions/local/post'; import {fetchMyChannel, fetchChannelById, fetchMissingDirectChannelsInfo} from '@actions/remote/channel'; import {fetchPostsForChannel} from '@actions/remote/post'; @@ -255,6 +255,31 @@ describe('WebSocket Channel Actions', () => { expect(setCurrentTeamId).not.toHaveBeenCalled(); }); + + it('should call deletePostsForChannel when autotranslation is disabled', async () => { + jest.mocked(EphemeralStore.isConvertingChannel).mockReturnValue(false); + mockedGetChannelById.mockResolvedValue({...channelModel, autotranslation: true} as ChannelModel); + msg.data.channel = JSON.stringify({id: channelId, type: General.PRIVATE_CHANNEL, team_id: teamId, autotranslation: false}); + jest.mocked(updateChannelInfoFromChannel).mockResolvedValue({model: []}); + jest.spyOn(operator, 'handleChannel').mockResolvedValueOnce([]); + + await handleChannelUpdatedEvent(serverUrl, msg); + + expect(deletePostsForChannel).toHaveBeenCalledWith(serverUrl, channelId); + }); + + it('should not call deletePostsForChannel when autotranslation stays enabled', async () => { + jest.mocked(EphemeralStore.isConvertingChannel).mockReturnValue(false); + mockedGetChannelById.mockResolvedValue({...channelModel, autotranslation: true} as ChannelModel); + msg.data.channel = JSON.stringify({id: channelId, type: General.PRIVATE_CHANNEL, team_id: teamId, autotranslation: true}); + jest.mocked(updateChannelInfoFromChannel).mockResolvedValue({model: []}); + jest.spyOn(operator, 'handleChannel').mockResolvedValueOnce([]); + (deletePostsForChannel as jest.Mock).mockClear(); + + await handleChannelUpdatedEvent(serverUrl, msg); + + expect(deletePostsForChannel).not.toHaveBeenCalled(); + }); }); describe('handleChannelViewedEvent', () => { diff --git a/app/actions/websocket/channel.ts b/app/actions/websocket/channel.ts index 6d28ba0fc..57a70e1bf 100644 --- a/app/actions/websocket/channel.ts +++ b/app/actions/websocket/channel.ts @@ -4,7 +4,7 @@ import {addChannelToDefaultCategory, handleConvertedGMCategories} from '@actions/local/category'; import { markChannelAsViewed, removeCurrentUserFromChannel, setChannelDeleteAt, - storeMyChannelsForTeam, updateChannelInfoFromChannel, updateMyChannelFromWebsocket, + storeMyChannelsForTeam, updateChannelInfoFromChannel, updateMyChannelFromWebsocket, deletePostsForChannel, } from '@actions/local/channel'; import {storePostsForChannel} from '@actions/local/post'; import {fetchMissingDirectChannelsInfo, fetchMyChannel, fetchChannelStats, fetchChannelById, handleKickFromChannel} from '@actions/remote/channel'; @@ -105,6 +105,11 @@ export async function handleChannelUpdatedEvent(serverUrl: string, msg: any) { const existingChannel = await getChannelById(database, updatedChannel.id); const existingChannelType = existingChannel?.type; + const autotranslationDisabled = existingChannel?.autotranslation && !updatedChannel.autotranslation; + if (autotranslationDisabled) { + await deletePostsForChannel(serverUrl, updatedChannel.id); + } + const models: Model[] = await operator.handleChannel({channels: [updatedChannel], prepareRecordsOnly: true}); const infoModel = await updateChannelInfoFromChannel(serverUrl, updatedChannel, true); if (infoModel.model) { diff --git a/app/actions/websocket/event.ts b/app/actions/websocket/event.ts index 7bf4afce7..53604db05 100644 --- a/app/actions/websocket/event.ts +++ b/app/actions/websocket/event.ts @@ -19,6 +19,7 @@ import * as channel from './channel'; import * as group from './group'; import {handleOpenDialogEvent} from './integrations'; import * as posts from './posts'; +import {handlePostTranslationUpdatedEvent} from './posts'; import * as preferences from './preferences'; import {handleAddCustomEmoji, handleReactionRemovedFromPostEvent, handleReactionAddedToPostEvent} from './reactions'; import {handleUserRoleUpdatedEvent, handleTeamMemberRoleUpdatedEvent, handleRoleUpdatedEvent} from './roles'; @@ -324,6 +325,11 @@ export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMess case WebsocketEvents.BURN_ON_READ_ALL_REVEALED: handleBoRPostAllRevealed(serverUrl, msg); break; + + // Autotranslation + case WebsocketEvents.POST_TRANSLATION_UPDATED: + handlePostTranslationUpdatedEvent(serverUrl, msg); + break; } handlePlaybookEvents(serverUrl, msg); } diff --git a/app/actions/websocket/posts.ts b/app/actions/websocket/posts.ts index 57b966aef..b0c7d86a3 100644 --- a/app/actions/websocket/posts.ts +++ b/app/actions/websocket/posts.ts @@ -6,8 +6,9 @@ import {isAgentPost} from '@agents/utils'; import {DeviceEventEmitter} from 'react-native'; import {storeMyChannelsForTeam, markChannelAsUnread, markChannelAsViewed, updateLastPostAt} from '@actions/local/channel'; -import {addPostAcknowledgement, markPostAsDeleted, removePostAcknowledgement} from '@actions/local/post'; +import {addPostAcknowledgement, markPostAsDeleted, removePostAcknowledgement, updatePostTranslation} from '@actions/local/post'; import {createThreadFromNewPost, updateThread} from '@actions/local/thread'; +import {getCurrentUserLocale} from '@actions/local/user'; import {fetchChannelStats, fetchMyChannel} from '@actions/remote/channel'; import {fetchPostAuthors, fetchPostById} from '@actions/remote/post'; import {openChannelIfNeeded} from '@actions/remote/preference'; @@ -404,3 +405,24 @@ export async function handlePostAcknowledgementRemoved(serverUrl: string, msg: W // Do nothing } } + +export async function handlePostTranslationUpdatedEvent(serverUrl: string, msg: WebSocketMessage) { + try { + const translationData: PostTranslationUpdateData = msg.data; + const translationObject: PostTranslation['object'] = translationData.translation ? JSON.parse(translationData.translation) : undefined; + const locale = await getCurrentUserLocale(serverUrl); + if (locale !== translationData.language) { + return; + } + if (locale === translationData.src_lang) { + return; + } + await updatePostTranslation(serverUrl, translationData.object_id, translationData.language, { + object: translationObject, + state: translationData.state, + source_lang: translationData.src_lang, + }); + } catch (error) { + // Do nothing + } +} diff --git a/app/actions/websocket/users.ts b/app/actions/websocket/users.ts index 32b8239eb..bb405535d 100644 --- a/app/actions/websocket/users.ts +++ b/app/actions/websocket/users.ts @@ -3,7 +3,7 @@ import {DeviceEventEmitter} from 'react-native'; -import {updateChannelsDisplayName} from '@actions/local/channel'; +import {deletePostsForChannelsWithAutotranslation, updateChannelsDisplayName} from '@actions/local/channel'; import {setCurrentUserStatus} from '@actions/local/user'; import {fetchMe, fetchUsersByIds} from '@actions/remote/user'; import {General, Events, Preferences} from '@constants'; @@ -59,6 +59,9 @@ export async function handleUserUpdatedEvent(serverUrl: string, msg: WebSocketMe modelsToBatch.push(...models); } } + + // Delete posts for all channels with user autotranslation enabled when locale changes + await deletePostsForChannelsWithAutotranslation(serverUrl); } } } else { diff --git a/app/client/rest/channels.test.ts b/app/client/rest/channels.test.ts index 38c6aae84..2df7745a6 100644 --- a/app/client/rest/channels.test.ts +++ b/app/client/rest/channels.test.ts @@ -137,6 +137,33 @@ describe('ClientChannels', () => { expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); }); + test('setChannelAutotranslation', async () => { + const channelId = 'channel1'; + const enabled = true; + const expectedUrl = `${client.getChannelRoute(channelId)}/patch`; + const expectedOptions = {method: 'put', body: {autotranslation: enabled}}; + jest.mocked(client.doFetch).mockResolvedValue({id: channelId, autotranslation: enabled}); + + const result = await client.setChannelAutotranslation(channelId, enabled); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result.autotranslation).toBe(enabled); + }); + + test('setMyChannelAutotranslation', async () => { + const channelId = 'channel1'; + const enabled = false; + const expectedUrl = `${client.getChannelMemberRoute(channelId, 'me')}/autotranslation`; + const expectedOptions = {method: 'put', body: {autotranslation_disabled: !enabled}}; + const mockMembership = {id: 'me-channel1', channel_id: channelId, user_id: 'me', autotranslation_disabled: !enabled}; + jest.mocked(client.doFetch).mockResolvedValue(mockMembership); + + const result = await client.setMyChannelAutotranslation(channelId, enabled); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockMembership); + }); + test('getChannel', async () => { const channelId = 'channel1'; const expectedUrl = client.getChannelRoute(channelId); diff --git a/app/client/rest/channels.ts b/app/client/rest/channels.ts index 01b3eb8a7..e66289877 100644 --- a/app/client/rest/channels.ts +++ b/app/client/rest/channels.ts @@ -19,6 +19,8 @@ export interface ClientChannelsMix { updateChannelPrivacy: (channelId: string, privacy: any) => Promise; patchChannel: (channelId: string, channelPatch: Partial) => Promise; updateChannelNotifyProps: (props: ChannelNotifyProps & {channel_id: string; user_id: string}) => Promise; + setChannelAutotranslation: (channelId: string, enabled: boolean) => Promise; + setMyChannelAutotranslation: (channelId: string, enabled: boolean) => Promise; getChannel: (channelId: string, groupLabel?: RequestGroupLabel) => Promise; getChannelByName: (teamId: string, channelName: string, includeDeleted?: boolean) => Promise; getChannelByNameAndTeamName: (teamName: string, channelName: string, includeDeleted?: boolean) => Promise; @@ -157,6 +159,17 @@ const ClientChannels = >(superclass: TBase ); }; + setChannelAutotranslation = async (channelId: string, enabled: boolean) => { + return this.patchChannel(channelId, {autotranslation: enabled}); + }; + + setMyChannelAutotranslation = async (channelId: string, enabled: boolean) => { + return this.doFetch( + `${this.getChannelMemberRoute(channelId, 'me')}/autotranslation`, + {method: 'put', body: {autotranslation_disabled: !enabled}}, + ); + }; + getChannel = async (channelId: string, groupLabel?: RequestGroupLabel) => { return this.doFetch( this.getChannelRoute(channelId), diff --git a/app/components/common_post_options/index.ts b/app/components/common_post_options/index.ts index 743001197..5ee302122 100644 --- a/app/components/common_post_options/index.ts +++ b/app/components/common_post_options/index.ts @@ -6,3 +6,4 @@ export {default as CopyPermalinkOption} from './copy_permalink_option'; export {default as FollowThreadOption} from './follow_thread_option'; export {default as ReplyOption} from './reply_option'; export {default as SaveOption} from './save_option'; +export {default as ShowTranslationOption} from './show_translation_option'; diff --git a/app/components/common_post_options/show_translation_option.tsx b/app/components/common_post_options/show_translation_option.tsx new file mode 100644 index 000000000..841153442 --- /dev/null +++ b/app/components/common_post_options/show_translation_option.tsx @@ -0,0 +1,42 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {defineMessages, useIntl} from 'react-intl'; + +import {BaseOption} from '@components/common_post_options'; +import {Screens} from '@constants'; +import {dismissBottomSheet, goToScreen} from '@screens/navigation'; + +import type {AvailableScreens} from '@typings/screens/navigation'; + +type Props = { + bottomSheetId: AvailableScreens; + postId: string; +} + +const messages = defineMessages({ + showTranslation: { + id: 'mobile.post_info.show_translation', + defaultMessage: 'Show Translation', + }, +}); + +const ShowTranslationOption = ({bottomSheetId, postId}: Props) => { + const intl = useIntl(); + const onHandlePress = useCallback(async () => { + await dismissBottomSheet(bottomSheetId); + goToScreen(Screens.SHOW_TRANSLATION, intl.formatMessage(messages.showTranslation), {postId}); + }, [bottomSheetId, intl, postId]); + + return ( + + ); +}; + +export default ShowTranslationOption; diff --git a/app/components/navigation_header/header.tsx b/app/components/navigation_header/header.tsx index 14acfe5c8..979e52770 100644 --- a/app/components/navigation_header/header.tsx +++ b/app/components/navigation_header/header.tsx @@ -38,6 +38,7 @@ type Props = { subtitleCompanion?: React.ReactElement; theme: Theme; title?: string; + titleCompanion?: React.ReactElement; } const hitSlop = {top: 20, bottom: 20, left: 20, right: 20}; @@ -126,6 +127,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ color: theme.sidebarHeaderTextColor, ...typography('Heading', 300), }, + titleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, })); const Header = ({ @@ -143,6 +149,7 @@ const Header = ({ subtitleCompanion, theme, title, + titleCompanion, }: Props) => { const styles = getStyleSheet(theme); const insets = useSafeAreaInsets(); @@ -216,14 +223,17 @@ const Header = ({ > {!hasSearch && - - {title} - + + + {title} + + {titleCompanion} + } {!isLargeTitle && Boolean(subtitle || subtitleCompanion) && diff --git a/app/components/navigation_header/index.tsx b/app/components/navigation_header/index.tsx index dae9d1c28..b1426b7ba 100644 --- a/app/components/navigation_header/index.tsx +++ b/app/components/navigation_header/index.tsx @@ -30,6 +30,7 @@ type Props = SearchProps & { subtitle?: string; subtitleCompanion?: React.ReactElement; title?: string; + titleCompanion?: React.ReactElement; } const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ @@ -54,6 +55,7 @@ const NavigationHeader = forwardRef(({ subtitle, subtitleCompanion, title = '', + titleCompanion, hideHeader, ...searchProps }: Props, ref) => { @@ -101,6 +103,7 @@ const NavigationHeader = forwardRef(({ subtitleCompanion={subtitleCompanion} theme={theme} title={title} + titleCompanion={titleCompanion} /> {isLargeTitle && { minHeight: ITEM_HEIGHT, gap: 12, justifyContent: 'space-between', + paddingVertical: 12, + }, + disabled: { + opacity: 0.6, }, destructive: { color: theme.dndIndicator, @@ -114,6 +118,7 @@ export type OptionItemProps = { action?: (React.Dispatch>)|((value: string | boolean) => void); description?: string; destructive?: boolean; + disabled?: boolean; icon?: string; iconColor?: string; info?: string | UserChipData; @@ -136,6 +141,7 @@ const OptionItem = ({ action, description, destructive, + disabled = false, icon, iconColor, info, @@ -161,8 +167,9 @@ const OptionItem = ({ const labelContainerStyle = useMemo(() => { const extraStyle = longInfo ? {flex: undefined} : {}; - return [styles.labelContainer, extraStyle]; - }, [longInfo, styles.labelContainer]); + const alignmentStyle = description ? {alignItems: 'flex-start' as const} : {}; + return [styles.labelContainer, extraStyle, alignmentStyle]; + }, [longInfo, styles.labelContainer, description]); const labelStyle = useMemo(() => { return isInLine ? styles.inlineLabel : styles.label; @@ -194,6 +201,10 @@ const OptionItem = ({ return [styles.actionContainer, extraStyle]; }, [longInfo, styles.actionContainer, styles.shrink]); + const containerStyle = useMemo(() => { + return disabled ? [styles.container, styles.disabled] : styles.container; + }, [disabled, styles.container, styles.disabled]); + let actionComponent; let radioComponent; if (type === OptionTypeConst.SELECT && selected) { @@ -297,7 +308,7 @@ const OptionItem = ({ const component = ( diff --git a/app/components/post_list/index.ts b/app/components/post_list/index.ts index 0bb27ab81..298bb05d5 100644 --- a/app/components/post_list/index.ts +++ b/app/components/post_list/index.ts @@ -6,6 +6,7 @@ import React from 'react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; +import {observeIsChannelAutotranslated} from '@queries/servers/channel'; import {queryAllCustomEmojis} from '@queries/servers/custom_emoji'; import {observeSavedPostsByIds, observeIsPostAcknowledgementsEnabled} from '@queries/servers/post'; import {observeConfigBooleanValue} from '@queries/servers/system'; @@ -18,8 +19,13 @@ import PostList from './post_list'; import type {WithDatabaseArgs} from '@typings/database/database'; import type PostModel from '@typings/database/models/servers/post'; -const enhancedWithoutPosts = withObservables([], ({database}: WithDatabaseArgs) => { +type OwnProps = { + channelId: string; +} & WithDatabaseArgs; + +const enhancedWithoutPosts = withObservables(['channelId'], ({database, channelId}: OwnProps) => { const currentUser = observeCurrentUser(database); + const isChannelAutotranslated = observeIsChannelAutotranslated(database, channelId); return { appsEnabled: observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'), currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user?.timezone || null))))), @@ -29,6 +35,7 @@ const enhancedWithoutPosts = withObservables([], ({database}: WithDatabaseArgs) switchMap((customEmojis) => of$(mapCustomEmojiNames(customEmojis))), ), isPostAcknowledgementEnabled: observeIsPostAcknowledgementsEnabled(database), + isChannelAutotranslated, }; }); diff --git a/app/components/post_list/post/body/index.tsx b/app/components/post_list/post/body/index.tsx index 0d89ba466..e99c6bef2 100644 --- a/app/components/post_list/post/body/index.tsx +++ b/app/components/post_list/post/body/index.tsx @@ -46,6 +46,7 @@ type BodyProps = { searchPatterns?: SearchPattern[]; showAddReaction?: boolean; theme: Theme; + isChannelAutotranslated: boolean; }; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { @@ -89,9 +90,25 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }); const Body = ({ - appsEnabled, hasFiles, hasReactions, highlight, highlightReplyBar, - isCRTEnabled, isEphemeral, isFirstReply, isJumboEmoji, isLastReply, isPendingOrFailed, isPostAcknowledgementEnabled, isPostAddChannelMember, - location, post, searchPatterns, showAddReaction, theme, + appsEnabled, + hasFiles, + hasReactions, + highlight, + highlightReplyBar, + isCRTEnabled, + isEphemeral, + isFirstReply, + isJumboEmoji, + isLastReply, + isPendingOrFailed, + isPostAcknowledgementEnabled, + isPostAddChannelMember, + location, + post, + searchPatterns, + showAddReaction, + theme, + isChannelAutotranslated, }: BodyProps) => { const intl = useIntl(); const style = getStyleSheet(theme); @@ -180,12 +197,14 @@ const Body = ({ post={post} searchPatterns={searchPatterns} theme={theme} + isChannelAutotranslated={isChannelAutotranslated} /> ); } const acknowledgementsVisible = isPostAcknowledgementEnabled && post.metadata?.priority?.requested_ack; const reactionsVisible = hasReactions && showAddReaction; + if (!hasBeenDeleted) { body = ( diff --git a/app/components/post_list/post/body/message/message.tsx b/app/components/post_list/post/body/message/message.tsx index 51d1a41a4..be14387f6 100644 --- a/app/components/post_list/post/body/message/message.tsx +++ b/app/components/post_list/post/body/message/message.tsx @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import React, {useCallback, useMemo, useState} from 'react'; +import {useIntl} from 'react-intl'; import {type LayoutChangeEvent, ScrollView, useWindowDimensions, View} from 'react-native'; import Animated from 'react-native-reanimated'; @@ -9,6 +10,7 @@ import Markdown from '@components/markdown'; import {isChannelMentions} from '@components/markdown/channel_mention/channel_mention'; import {SEARCH} from '@constants/screens'; import {useShowMoreAnimatedStyle} from '@hooks/show_more'; +import {getPostTranslatedMessage, getPostTranslation} from '@utils/post'; import {makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; @@ -31,6 +33,7 @@ type MessageProps = { post: PostModel; searchPatterns?: SearchPattern[]; theme: Theme; + isChannelAutotranslated: boolean; } const SHOW_MORE_HEIGHT = 54; @@ -56,13 +59,27 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }; }); -const Message = ({currentUser, isHighlightWithoutNotificationLicensed, highlight, isEdited, isPendingOrFailed, isReplyPost, layoutWidth, location, post, searchPatterns, theme}: MessageProps) => { +const Message = ({ + currentUser, + isHighlightWithoutNotificationLicensed, + highlight, + isEdited, + isPendingOrFailed, + isReplyPost, + layoutWidth, + location, + post, + searchPatterns, + theme, + isChannelAutotranslated, +}: MessageProps) => { const [open, setOpen] = useState(false); const [height, setHeight] = useState(); const dimensions = useWindowDimensions(); const maxHeight = Math.round((dimensions.height * 0.5) + SHOW_MORE_HEIGHT); const animatedStyle = useShowMoreAnimatedStyle(height, maxHeight, open); const style = getStyleSheet(theme); + const intl = useIntl(); // We need to memoize these two values because they are actually getters that return a new list // on every render. We need to trust that changes in the currentUser will trigger the recalculation. @@ -86,6 +103,12 @@ const Message = ({currentUser, isHighlightWithoutNotificationLicensed, highlight return isChannelMentions(post.props?.channel_mentions) ? post.props.channel_mentions : {}; }, [post.props?.channel_mentions]); + const translation = getPostTranslation(post, intl.locale); + let message = post.message; + if (isChannelAutotranslated && post.type === '' && translation?.state === 'ready') { + message = getPostTranslatedMessage(post.message, translation); + } + return ( <> @@ -110,7 +133,7 @@ const Message = ({currentUser, isHighlightWithoutNotificationLicensed, highlight layoutWidth={layoutWidth} location={location} postId={post.id} - value={post.message} + value={message} mentionKeys={mentionKeys} highlightKeys={highlightKeys} searchPatterns={searchPatterns} diff --git a/app/components/post_list/post/burn_on_read/unrevealed/index.tsx b/app/components/post_list/post/burn_on_read/unrevealed/index.tsx index 55d6a7514..be02b08f2 100644 --- a/app/components/post_list/post/burn_on_read/unrevealed/index.tsx +++ b/app/components/post_list/post/burn_on_read/unrevealed/index.tsx @@ -9,13 +9,14 @@ import {revealBoRPost} from '@actions/remote/post'; import Button from '@components/button'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; -import {PostModel} from '@database/models/server'; import {getFullErrorMessage, getServerError} from '@utils/errors'; import {showBoRPostErrorSnackbar} from '@utils/snack_bar'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {BOR_ERROR_CODES} from './constants'; +import type PostModel from '@typings/database/models/servers/post'; + const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ buttonBackgroundStyle: { backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), diff --git a/app/components/post_list/post/header/header.test.tsx b/app/components/post_list/post/header/header.test.tsx index 6bb218647..e67135249 100644 --- a/app/components/post_list/post/header/header.test.tsx +++ b/app/components/post_list/post/header/header.test.tsx @@ -46,6 +46,7 @@ describe('Header', () => { teammateNameDisplay: '', hideGuestTags: false, currentUser, + isChannelAutotranslated: false, }; it('Should show BoR icon for own BoR post', () => { diff --git a/app/components/post_list/post/header/header.tsx b/app/components/post_list/post/header/header.tsx index d8ea0179e..f57f82d6e 100644 --- a/app/components/post_list/post/header/header.tsx +++ b/app/components/post_list/post/header/header.tsx @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import React, {useCallback, useMemo} from 'react'; +import {useIntl} from 'react-intl'; import {View} from 'react-native'; import {removePost} from '@actions/local/post'; @@ -15,7 +16,7 @@ import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {DEFAULT_LOCALE} from '@i18n'; import {isUnrevealedBoRPost} from '@utils/bor'; -import {postUserDisplayName} from '@utils/post'; +import {getPostTranslation, postUserDisplayName} from '@utils/post'; import {makeStyleSheetFromTheme} from '@utils/theme'; import {ensureString} from '@utils/types'; import {typography} from '@utils/typography'; @@ -25,6 +26,7 @@ import HeaderCommentedOn from './commented_on'; import HeaderDisplayName from './display_name'; import HeaderReply from './reply'; import HeaderTag from './tag'; +import TranslateIcon from './translate_icon'; import type PostModel from '@typings/database/models/servers/post'; import type UserModel from '@typings/database/models/servers/user'; @@ -50,6 +52,7 @@ type HeaderProps = { shouldRenderReplyButton?: boolean; teammateNameDisplay: string; hideGuestTags: boolean; + isChannelAutotranslated: boolean; } const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { @@ -80,12 +83,28 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }; }); -const Header = (props: HeaderProps) => { - const { - author, commentCount = 0, currentUser, enablePostUsernameOverride, isAutoResponse, isCRTEnabled, isCustomStatusEnabled, - isEphemeral, isMilitaryTime, isPendingOrFailed, isSystemPost, isWebHook, - location, post, rootPostAuthor, showPostPriority, shouldRenderReplyButton, teammateNameDisplay, hideGuestTags, - } = props; +const Header = ({ + commentCount, + enablePostUsernameOverride, + hideGuestTags, + isAutoResponse, + isChannelAutotranslated, + isCustomStatusEnabled, + isEphemeral, + isMilitaryTime, + isPendingOrFailed, + isSystemPost, + isWebHook, + location, + post, + showPostPriority, + teammateNameDisplay, + author, + currentUser, + isCRTEnabled, + rootPostAuthor, + shouldRenderReplyButton, +}: HeaderProps) => { const theme = useTheme(); const style = getStyleSheet(theme); const pendingPostStyle = isPendingOrFailed ? style.pendingPost : undefined; @@ -100,6 +119,7 @@ const Header = (props: HeaderProps) => { ) && !isCustomStatusExpired(author) && Boolean(customStatus?.emoji); const userIconOverride = ensureString(post.props?.override_icon_url); const usernameOverride = ensureString(post.props?.override_username); + const intl = useIntl(); const showBoRIcon = useMemo(() => isUnrevealedBoRPost(post), [post, post.metadata?.expire_at]); const borExpireAt = post.metadata?.expire_at; @@ -109,6 +129,8 @@ const Header = (props: HeaderProps) => { await removePost(serverUrl, post); }, [post, serverUrl]); + const translation = getPostTranslation(post, intl.locale); + return ( <> @@ -141,6 +163,9 @@ const Header = (props: HeaderProps) => { style={style.time} testID='post_header.date_time' /> + {isChannelAutotranslated && post.type === '' && + + } {isEphemeral && ( { + return { + translationProcessing: { + color: theme.centerChannelColor, + opacity: 0.75, + ...typography('Body', 75, 'Regular'), + }, + }; +}); + +function TranslateIcon({ + translationState, +}: Props) { + const theme = useTheme(); + const style = getStyleSheet(theme); + + if (translationState === 'ready') { + return ( + + ); + } + + if (translationState === 'processing') { + return ( + <> + + + + ); + } + + if (translationState === 'unavailable') { + return ( + + ); + } + + return null; +} + +export default TranslateIcon; diff --git a/app/components/post_list/post/index.ts b/app/components/post_list/post/index.ts index 9626ee26a..d9197b3c5 100644 --- a/app/components/post_list/post/index.ts +++ b/app/components/post_list/post/index.ts @@ -7,6 +7,7 @@ import {of as of$, combineLatest} from 'rxjs'; import {switchMap, distinctUntilChanged} from 'rxjs/operators'; import {Permissions, Preferences, Screens} from '@constants'; +import {DEFAULT_LOCALE} from '@i18n'; import {queryFilesForPost} from '@queries/servers/file'; import {observePost, observePostAuthor, queryPostsBetween, observeIsPostPriorityEnabled} from '@queries/servers/post'; import {queryReactionsForPost} from '@queries/servers/reaction'; @@ -86,6 +87,21 @@ function isFirstReply(post: PostModel, previousPost?: PostModel) { return false; } +function observeIsConsecutivePost(database: Database, post: PostModel, userLocale: string, previousPost?: PostModel) { + if (isBoRPost(post)) { + return of$(false); + } + if (!post ||!previousPost) { + return of$(false); + } + + const author = post.userId ? observePostAuthor(database, post) : of$(undefined); + return author.pipe( + switchMap((user) => of$(Boolean(!user?.isBot && areConsecutivePosts(post, previousPost, userLocale)))), + distinctUntilChanged(), + ); +} + const withSystem = withObservables([], ({database}: WithDatabaseArgs) => ({ currentUser: observeCurrentUser(database), })); @@ -96,7 +112,6 @@ const withPost = withObservables( let isLastReply = of$(true); let isPostAddChannelMember = of$(false); const isOwner = currentUser?.id === post.userId; - const author = post.userId ? observePostAuthor(database, post) : of$(undefined); const canDelete = observePermissionForPost(database, post, currentUser, isOwner ? Permissions.DELETE_POST : Permissions.DELETE_OTHERS_POSTS, false); const isEphemeral = of$(isPostEphemeral(post)); @@ -127,10 +142,7 @@ const withPost = withObservables( // Don't combine consecutive Burn on Read posts as we want each BoR post // to display its header to allow displaying the remaining time. - const isConsecutivePost = isBoRPost(post) ? of$(false) : author.pipe( - switchMap((user) => of$(Boolean(post && previousPost && !user?.isBot && areConsecutivePosts(post, previousPost)))), - distinctUntilChanged(), - ); + const isConsecutivePost = observeIsConsecutivePost(database, post, currentUser?.locale || DEFAULT_LOCALE, previousPost); const hasFiles = queryFilesForPost(database, post.id).observeCount().pipe( switchMap((c) => of$(c > 0)), diff --git a/app/components/post_list/post/post.test.tsx b/app/components/post_list/post/post.test.tsx index bfee993eb..078f5add2 100644 --- a/app/components/post_list/post/post.test.tsx +++ b/app/components/post_list/post/post.test.tsx @@ -41,6 +41,7 @@ describe('performance metrics', () => { location: 'Channel', post, isLastPost: true, + isChannelAutotranslated: false, }; } diff --git a/app/components/post_list/post/post.tsx b/app/components/post_list/post/post.tsx index c27d8afee..1f877132b 100644 --- a/app/components/post_list/post/post.tsx +++ b/app/components/post_list/post/post.tsx @@ -5,7 +5,7 @@ import AgentPost from '@agents/components/agent_post'; import {isAgentPost} from '@agents/utils'; import React, {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; -import {Platform, type StyleProp, View, type ViewStyle, TouchableHighlight} from 'react-native'; +import {Platform, type StyleProp, View, type ViewStyle, TouchableHighlight, type LayoutChangeEvent} from 'react-native'; import {KeyboardController} from 'react-native-keyboard-controller'; import {removePost} from '@actions/local/post'; @@ -34,8 +34,10 @@ import Body from './body'; import Footer from './footer'; import Header from './header'; import PreHeader from './pre_header'; +import ShimmerAnimation from './shimmer_animation'; import SystemMessage from './system_message'; import UnreadDot from './unread_dot'; +import useShimmerAnimation from './use_shimmer_animation'; import type PostModel from '@typings/database/models/servers/post'; import type ThreadModel from '@typings/database/models/servers/thread'; @@ -77,6 +79,7 @@ type PostProps = { style?: StyleProp; testID?: string; thread?: ThreadModel; + isChannelAutotranslated: boolean; }; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { @@ -150,6 +153,7 @@ const Post = ({ thread, previousPost, isLastPost, + isChannelAutotranslated, }: PostProps) => { const pressDetected = useRef(false); const intl = useIntl(); @@ -169,6 +173,8 @@ const Post = ({ const isAgentPostType = isAgentPost(post); const hasBeenDeleted = (post.deleteAt !== 0); const isWebHook = isFromWebhook(post); + const [layoutWidth, setLayoutWidth] = useState(0); + const shimmerAnimationProps = useShimmerAnimation(post, isChannelAutotranslated, intl.locale, layoutWidth, theme); const hasSameRoot = useMemo(() => { if (isFirstReply) { return false; @@ -285,6 +291,10 @@ const Post = ({ // eslint-disable-next-line react-hooks/exhaustive-deps -- Performance metrics should only run once on mount }, []); + const onLayout = useCallback((e: LayoutChangeEvent) => { + setLayoutWidth(e.nativeEvent.layout.width); + }, []); + const highlightSaved = isSaved && !skipSavedHeader; const hightlightPinned = post.isPinned && !skipPinnedHeader; const itemTestID = `${testID}.${post.id}`; @@ -349,6 +359,7 @@ const Post = ({ post={post} showPostPriority={showPostPriority} shouldRenderReplyButton={shouldRenderReplyButton} + isChannelAutotranslated={isChannelAutotranslated} /> ); } @@ -408,6 +419,7 @@ const Post = ({ searchPatterns={searchPatterns} showAddReaction={showAddReaction} theme={theme} + isChannelAutotranslated={isChannelAutotranslated} /> ); } @@ -435,6 +447,7 @@ const Post = ({ + ); }; diff --git a/app/components/post_list/post/shimmer_animation.tsx b/app/components/post_list/post/shimmer_animation.tsx new file mode 100644 index 000000000..38fd03f47 --- /dev/null +++ b/app/components/post_list/post/shimmer_animation.tsx @@ -0,0 +1,72 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {LinearGradient} from 'expo-linear-gradient'; +import {StyleSheet, View} from 'react-native'; +import Animated from 'react-native-reanimated'; + +import type {ComponentProps} from 'react'; + +type Props = { + isTranslating: boolean; + backgroundColor: string; + shimmerAnimatedStyle: ComponentProps['style']; + gradientColors: ComponentProps['colors']; +}; + +const shimmerStyles = StyleSheet.create({ + shimmerContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + overflow: 'hidden', + }, + shimmerBackground: { + ...StyleSheet.absoluteFillObject, + }, + shimmerWrapper: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + width: '300%', + }, +}); + +const gradientSettings = { + locations: [0, 0.3, 0.5, 0.7, 1] as const, + start: {x: 0, y: 0}, + end: {x: 0.766, y: 0.643}, // 130 degree angle (more severe) +}; + +const ShimmerAnimation = ({ + isTranslating, + backgroundColor, + shimmerAnimatedStyle, + gradientColors, +}: Props) => { + if (!isTranslating) { + return null; + } + return ( + + + + + + + ); +}; + +export default ShimmerAnimation; diff --git a/app/components/post_list/post/use_shimmer_animation.tsx b/app/components/post_list/post/use_shimmer_animation.tsx new file mode 100644 index 000000000..9ad23498c --- /dev/null +++ b/app/components/post_list/post/use_shimmer_animation.tsx @@ -0,0 +1,76 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useEffect, useMemo} from 'react'; +import {cancelAnimation, Easing, interpolate, useAnimatedStyle, useSharedValue, withRepeat, withTiming} from 'react-native-reanimated'; + +import EphemeralStore from '@store/ephemeral_store'; +import {getPostTranslation} from '@utils/post'; +import {changeOpacity} from '@utils/theme'; + +import type PostModel from '@typings/database/models/servers/post'; + +const MAX_RUNNING_TRANSLATIONS = 10; + +const useShimmerAnimation = (post: PostModel, isChannelAutotranslated: boolean, locale: string, layoutWidth: number, theme: Theme) => { + const translation = getPostTranslation(post, locale); + const isTranslating = isChannelAutotranslated && post.type === '' && translation?.state === 'processing'; + const shimmerTranslateX = useSharedValue(-1); + + useEffect(() => { + if (isTranslating) { + if (EphemeralStore.totalRunningTranslations() < MAX_RUNNING_TRANSLATIONS) { + EphemeralStore.addRunningTranslation(post.id); + shimmerTranslateX.value = withRepeat( + withTiming(1, { + duration: 2000, + easing: Easing.linear, + }), + -1, + ); + } else { + shimmerTranslateX.value = 0; + } + } + + return () => { + EphemeralStore.removeRunningTranslation(post.id); + cancelAnimation(shimmerTranslateX); + shimmerTranslateX.value = -1; + }; + }, [isTranslating, post.id, shimmerTranslateX]); + + const shimmerAnimatedStyle = useAnimatedStyle(() => { + const translateX = interpolate( + shimmerTranslateX.value, + [-1, 1], + [-4 * layoutWidth, 4 * layoutWidth], + ); + return { + transform: [{translateX}], + }; + }); + + const gradientColors = useMemo(() => { + return [ + changeOpacity(theme.centerChannelBg, 0.0), + changeOpacity(theme.centerChannelBg, 0.4), + theme.centerChannelBg, + changeOpacity(theme.centerChannelBg, 0.4), + changeOpacity(theme.centerChannelBg, 0.0), + ] as const; + }, [theme]); + + const backgroundColor = useMemo(() => { + return changeOpacity(theme.centerChannelBg, 0.32); + }, [theme]); + + return { + backgroundColor, + gradientColors, + shimmerAnimatedStyle, + isTranslating, + }; +}; + +export default useShimmerAnimation; diff --git a/app/components/post_list/post_list.test.tsx b/app/components/post_list/post_list.test.tsx index 5247dbcb3..43c338390 100644 --- a/app/components/post_list/post_list.test.tsx +++ b/app/components/post_list/post_list.test.tsx @@ -79,6 +79,7 @@ describe('components/post_list/PostList', () => { savedPostIds: new Set(), testID: 'post_list', shouldShowJoinLeaveMessages: false, + isChannelAutotranslated: false, listRef: createRef>(), }; diff --git a/app/components/post_list/post_list.tsx b/app/components/post_list/post_list.tsx index ed6a4cc06..fe268ef44 100644 --- a/app/components/post_list/post_list.tsx +++ b/app/components/post_list/post_list.tsx @@ -57,6 +57,7 @@ type Props = { testID: string; currentCallBarVisible?: boolean; savedPostIds: Set; + isChannelAutotranslated: boolean; listRef?: React.RefObject>; onTouchMove?: (event: GestureResponderEvent) => void; onTouchEnd?: () => void; @@ -110,6 +111,7 @@ const PostList = ({ showNewMessageLine = true, testID, savedPostIds, + isChannelAutotranslated, listRef, onTouchMove, onTouchEnd, @@ -372,6 +374,7 @@ const PostList = ({ shouldRenderReplyButton, skipSaveddHeader, testID: `${testID}.post`, + isChannelAutotranslated, }; return ( @@ -382,7 +385,7 @@ const PostList = ({ ); } } - }, [appsEnabled, currentTimezone, currentUsername, customEmojiNames, highlightPinnedOrSaved, highlightedId, isCRTEnabled, isPostAcknowledgementEnabled, location, rootId, shouldRenderReplyButton, shouldShowJoinLeaveMessages, testID, theme]); + }, [appsEnabled, currentTimezone, currentUsername, customEmojiNames, highlightPinnedOrSaved, highlightedId, isCRTEnabled, isChannelAutotranslated, isPostAcknowledgementEnabled, location, rootId, shouldRenderReplyButton, shouldShowJoinLeaveMessages, testID, theme]); useEffect(() => { const t = setTimeout(() => { diff --git a/app/components/post_with_channel_info/index.ts b/app/components/post_with_channel_info/index.ts index 34a092aa4..ae49df775 100644 --- a/app/components/post_with_channel_info/index.ts +++ b/app/components/post_with_channel_info/index.ts @@ -4,6 +4,7 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; +import {observeIsChannelAutotranslated} from '@queries/servers/channel'; import {observePostSaved} from '@queries/servers/post'; import {observeIsCRTEnabled} from '@queries/servers/thread'; @@ -21,6 +22,7 @@ const enhance = withObservables(['post', 'skipSavedPostsHighlight'], ({database, return { isCRTEnabled: observeIsCRTEnabled(database), isSaved: skipSavedPostsHighlight ? of$(false) : observePostSaved(database, post.id), + isChannelAutotranslated: observeIsChannelAutotranslated(database, post.channelId), }; }); diff --git a/app/components/post_with_channel_info/post_with_channel_info.tsx b/app/components/post_with_channel_info/post_with_channel_info.tsx index 10bf91d11..8bb8d2d62 100644 --- a/app/components/post_with_channel_info/post_with_channel_info.tsx +++ b/app/components/post_with_channel_info/post_with_channel_info.tsx @@ -22,6 +22,7 @@ type Props = { searchPatterns?: SearchPattern[]; skipSavedPostsHighlight?: boolean; isSaved?: boolean; + isChannelAutotranslated: boolean; } const styles = StyleSheet.create({ @@ -35,7 +36,18 @@ const styles = StyleSheet.create({ }, }); -function PostWithChannelInfo({appsEnabled, customEmojiNames, isCRTEnabled, post, location, testID, searchPatterns, skipSavedPostsHighlight = false, isSaved}: Props) { +function PostWithChannelInfo({ + appsEnabled, + customEmojiNames, + isCRTEnabled, + post, + location, + testID, + searchPatterns, + skipSavedPostsHighlight = false, + isSaved, + isChannelAutotranslated, +}: Props) { return ( diff --git a/app/constants/events.ts b/app/constants/events.ts index e67bac329..6aee64e30 100644 --- a/app/constants/events.ts +++ b/app/constants/events.ts @@ -37,6 +37,7 @@ export default keyMirror({ ACTIVE_SCREEN: null, ACTIVE_SERVER_CHANGED: null, FILE_ADD_REMOVED: null, + POST_DELETED_FOR_CHANNEL: null, KEYBOARD_STATE_CHANGED: null, CLOSE_INPUT_ACCESSORY_VIEW: null, EMOJI_PICKER_SEARCH_FOCUSED: null, diff --git a/app/constants/screens.ts b/app/constants/screens.ts index 2ce7da1c1..dafbc3e96 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -19,6 +19,7 @@ export const CHANNEL_BOOKMARK = 'ChannelBookmarkAddOrEdit'; export const CHANNEL_FILES = 'ChannelFiles'; export const CHANNEL_INFO = 'ChannelInfo'; export const CHANNEL_NOTIFICATION_PREFERENCES = 'ChannelNotificationPreferences'; +export const CHANNEL_SETTINGS = 'ChannelSettings'; export const CODE = 'Code'; export const CONVERT_GM_TO_CHANNEL = 'ConvertGMToChannel'; export const CREATE_DIRECT_MESSAGE = 'CreateDirectMessage'; @@ -91,6 +92,7 @@ export const THREAD = 'Thread'; export const THREAD_FOLLOW_BUTTON = 'ThreadFollowButton'; export const THREAD_OPTIONS = 'ThreadOptions'; export const USER_PROFILE = 'UserProfile'; +export const SHOW_TRANSLATION = 'ShowTranslation'; export default { ABOUT, @@ -109,6 +111,7 @@ export default { CHANNEL_FILES, CHANNEL_INFO, CHANNEL_NOTIFICATION_PREFERENCES, + CHANNEL_SETTINGS, CODE, CONVERT_GM_TO_CHANNEL, COMPONENT_LIBRARY, @@ -180,6 +183,7 @@ export default { THREAD_FOLLOW_BUTTON, THREAD_OPTIONS, USER_PROFILE, + SHOW_TRANSLATION, ...PLAYBOOKS_SCREENS, } as const; diff --git a/app/constants/snack_bar.ts b/app/constants/snack_bar.ts index 0aac6aefc..f207f364a 100644 --- a/app/constants/snack_bar.ts +++ b/app/constants/snack_bar.ts @@ -29,6 +29,7 @@ export const SNACK_BAR_TYPE = keyMirror({ RESCHEDULED_POST: null, DELETE_SCHEDULED_POST_ERROR: null, PLAYBOOK_ERROR: null, + ENABLE_TRANSLATION: null, BOR_POST_EXPIRED: null, }); @@ -41,7 +42,7 @@ export const MESSAGE_TYPE = { export type SnackBarConfig = { message: MessageDescriptor; iconName: string; - canUndo: boolean; + hasAction: boolean; type?: typeof MESSAGE_TYPE[keyof typeof MESSAGE_TYPE]; }; @@ -118,6 +119,10 @@ const messages = defineMessages({ id: 'snack.bar.playbook.error', defaultMessage: 'Unable to perform action. Please try again later.', }, + ENABLE_TRANSLATION: { + id: 'snack.bar.enable.translation', + defaultMessage: 'Enable auto-translation?', + }, BOR_POST_EXPIRED: { id: 'snack.bar.bor_post_expired.error', defaultMessage: 'This burn-on-read post has expired and can no longer be revealed.', @@ -128,104 +133,109 @@ export const SNACK_BAR_CONFIG: Record = { ADD_CHANNEL_MEMBERS: { message: messages.ADD_CHANNEL_MEMBERS, iconName: 'check', - canUndo: false, + hasAction: false, }, AGENT_STOP_ERROR: { message: messages.AGENT_STOP_ERROR, iconName: 'alert-outline', - canUndo: false, + hasAction: false, type: MESSAGE_TYPE.ERROR, }, AGENT_REGENERATE_ERROR: { message: messages.AGENT_REGENERATE_ERROR, iconName: 'alert-outline', - canUndo: false, + hasAction: false, type: MESSAGE_TYPE.ERROR, }, AGENT_TOOL_APPROVAL_ERROR: { message: messages.AGENT_TOOL_APPROVAL_ERROR, iconName: 'alert-outline', - canUndo: false, + hasAction: false, type: MESSAGE_TYPE.ERROR, }, CODE_COPIED: { message: messages.CODE_COPIED, iconName: 'content-copy', - canUndo: false, + hasAction: false, }, FAVORITE_CHANNEL: { message: messages.FAVORITE_CHANNEL, iconName: 'star', - canUndo: true, + hasAction: true, }, FOLLOW_THREAD: { message: messages.FOLLOW_THREAD, iconName: 'check', - canUndo: true, + hasAction: true, }, INFO_COPIED: { message: messages.INFO_COPIED, iconName: 'content-copy', - canUndo: false, + hasAction: false, }, LINK_COPIED: { message: messages.LINK_COPIED, iconName: 'link-variant', - canUndo: false, + hasAction: false, type: MESSAGE_TYPE.SUCCESS, }, LINK_COPY_FAILED: { message: messages.LINK_COPY_FAILED, iconName: 'link-variant', - canUndo: false, + hasAction: false, type: MESSAGE_TYPE.ERROR, }, MESSAGE_COPIED: { message: messages.MESSAGE_COPIED, iconName: 'content-copy', - canUndo: false, + hasAction: false, }, MUTE_CHANNEL: { message: messages.MUTE_CHANNEL, iconName: 'bell-off-outline', - canUndo: true, + hasAction: true, }, REMOVE_CHANNEL_USER: { message: messages.REMOVE_CHANNEL_USER, iconName: 'check', - canUndo: true, + hasAction: true, }, TEXT_COPIED: { message: messages.TEXT_COPIED, iconName: 'content-copy', - canUndo: false, + hasAction: false, type: MESSAGE_TYPE.SUCCESS, }, UNFAVORITE_CHANNEL: { message: messages.UNFAVORITE_CHANNEL, iconName: 'star-outline', - canUndo: true, + hasAction: true, }, UNMUTE_CHANNEL: { message: messages.UNMUTE_CHANNEL, iconName: 'bell-outline', - canUndo: true, + hasAction: true, }, UNFOLLOW_THREAD: { message: messages.UNFOLLOW_THREAD, iconName: 'check', - canUndo: true, + hasAction: true, }, PLAYBOOK_ERROR: { message: messages.PLAYBOOK_ERROR, iconName: 'alert-outline', - canUndo: false, + hasAction: false, type: MESSAGE_TYPE.ERROR, }, + ENABLE_TRANSLATION: { + message: messages.ENABLE_TRANSLATION, + iconName: 'globe', + hasAction: true, + }, BOR_POST_EXPIRED: { message: messages.BOR_POST_EXPIRED, iconName: 'alert-outline', - canUndo: false, + hasAction: false, type: MESSAGE_TYPE.ERROR, }, }; diff --git a/app/constants/websocket.ts b/app/constants/websocket.ts index b5c0a2f9f..99dc78dcb 100644 --- a/app/constants/websocket.ts +++ b/app/constants/websocket.ts @@ -61,6 +61,7 @@ const WebsocketEvents = { DELETE_TEAM: 'delete_team', RESTORE_TEAM: 'restore_team', APPS_FRAMEWORK_REFRESH_BINDINGS: 'custom_com.mattermost.apps_refresh_bindings', + POST_TRANSLATION_UPDATED: 'post_translation_updated', CALLS_CHANNEL_ENABLED: `custom_${Calls.PluginId}_channel_enable_voice`, CALLS_CHANNEL_DISABLED: `custom_${Calls.PluginId}_channel_disable_voice`, diff --git a/app/i18n/index.ts b/app/i18n/index.ts index 7eb95e341..6482b463e 100644 --- a/app/i18n/index.ts +++ b/app/i18n/index.ts @@ -25,6 +25,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-datetimeformat/locale-data/bg'); require('@formatjs/intl-listformat/locale-data/bg'); require('@formatjs/intl-relativetimeformat/locale-data/bg'); + require('@formatjs/intl-displaynames/locale-data/bg'); translations = require('@assets/i18n/bg.json'); break; @@ -34,6 +35,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-datetimeformat/locale-data/de'); require('@formatjs/intl-listformat/locale-data/de'); require('@formatjs/intl-relativetimeformat/locale-data/de'); + require('@formatjs/intl-displaynames/locale-data/de'); translations = require('@assets/i18n/de.json'); break; @@ -43,6 +45,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-datetimeformat/locale-data/en'); require('@formatjs/intl-listformat/locale-data/en'); require('@formatjs/intl-relativetimeformat/locale-data/en'); + require('@formatjs/intl-displaynames/locale-data/en'); translations = require('@assets/i18n/en_AU.json'); break; @@ -52,6 +55,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-datetimeformat/locale-data/es'); require('@formatjs/intl-listformat/locale-data/es'); require('@formatjs/intl-relativetimeformat/locale-data/es'); + require('@formatjs/intl-displaynames/locale-data/es'); translations = require('@assets/i18n/es.json'); break; @@ -61,6 +65,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-datetimeformat/locale-data/fa'); require('@formatjs/intl-listformat/locale-data/fa'); require('@formatjs/intl-relativetimeformat/locale-data/fa'); + require('@formatjs/intl-displaynames/locale-data/fa'); translations = require('@assets/i18n/fa.json'); break; @@ -70,6 +75,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-datetimeformat/locale-data/fr'); require('@formatjs/intl-listformat/locale-data/fr'); require('@formatjs/intl-relativetimeformat/locale-data/fr'); + require('@formatjs/intl-displaynames/locale-data/fr'); translations = require('@assets/i18n/fr.json'); break; @@ -79,6 +85,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-datetimeformat/locale-data/hu'); require('@formatjs/intl-listformat/locale-data/hu'); require('@formatjs/intl-relativetimeformat/locale-data/hu'); + require('@formatjs/intl-displaynames/locale-data/hu'); translations = require('@assets/i18n/hu.json'); break; @@ -88,6 +95,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-datetimeformat/locale-data/it'); require('@formatjs/intl-listformat/locale-data/it'); require('@formatjs/intl-relativetimeformat/locale-data/it'); + require('@formatjs/intl-displaynames/locale-data/it'); translations = require('@assets/i18n/it.json'); break; @@ -97,6 +105,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-datetimeformat/locale-data/ja'); require('@formatjs/intl-listformat/locale-data/ja'); require('@formatjs/intl-relativetimeformat/locale-data/ja'); + require('@formatjs/intl-displaynames/locale-data/ja'); translations = require('@assets/i18n/ja.json'); break; @@ -106,6 +115,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-datetimeformat/locale-data/ko'); require('@formatjs/intl-listformat/locale-data/ko'); require('@formatjs/intl-relativetimeformat/locale-data/ko'); + require('@formatjs/intl-displaynames/locale-data/ko'); translations = require('@assets/i18n/ko.json'); break; @@ -115,6 +125,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-datetimeformat/locale-data/nl'); require('@formatjs/intl-listformat/locale-data/nl'); require('@formatjs/intl-relativetimeformat/locale-data/nl'); + require('@formatjs/intl-displaynames/locale-data/nl'); translations = require('@assets/i18n/nl.json'); break; @@ -124,6 +135,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-datetimeformat/locale-data/pl'); require('@formatjs/intl-listformat/locale-data/pl'); require('@formatjs/intl-relativetimeformat/locale-data/pl'); + require('@formatjs/intl-displaynames/locale-data/pl'); translations = require('@assets/i18n/pl.json'); break; @@ -133,6 +145,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-datetimeformat/locale-data/pt'); require('@formatjs/intl-listformat/locale-data/pt'); require('@formatjs/intl-relativetimeformat/locale-data/pt'); + require('@formatjs/intl-displaynames/locale-data/pt'); translations = require('@assets/i18n/pt-BR.json'); break; @@ -142,6 +155,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-datetimeformat/locale-data/ro'); require('@formatjs/intl-listformat/locale-data/ro'); require('@formatjs/intl-relativetimeformat/locale-data/ro'); + require('@formatjs/intl-displaynames/locale-data/ro'); translations = require('@assets/i18n/ro.json'); break; @@ -151,6 +165,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-datetimeformat/locale-data/ru'); require('@formatjs/intl-listformat/locale-data/ru'); require('@formatjs/intl-relativetimeformat/locale-data/ru'); + require('@formatjs/intl-displaynames/locale-data/ru'); translations = require('@assets/i18n/ru.json'); break; @@ -160,6 +175,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-datetimeformat/locale-data/sv'); require('@formatjs/intl-listformat/locale-data/sv'); require('@formatjs/intl-relativetimeformat/locale-data/sv'); + require('@formatjs/intl-displaynames/locale-data/sv'); translations = require('@assets/i18n/sv.json'); break; @@ -169,6 +185,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-datetimeformat/locale-data/tr'); require('@formatjs/intl-listformat/locale-data/tr'); require('@formatjs/intl-relativetimeformat/locale-data/tr'); + require('@formatjs/intl-displaynames/locale-data/tr'); translations = require('@assets/i18n/tr.json'); break; @@ -178,6 +195,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-datetimeformat/locale-data/uk'); require('@formatjs/intl-listformat/locale-data/uk'); require('@formatjs/intl-relativetimeformat/locale-data/uk'); + require('@formatjs/intl-displaynames/locale-data/uk'); translations = require('@assets/i18n/uk.json'); break; @@ -187,6 +205,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-datetimeformat/locale-data/vi'); require('@formatjs/intl-listformat/locale-data/vi'); require('@formatjs/intl-relativetimeformat/locale-data/vi'); + require('@formatjs/intl-displaynames/locale-data/vi'); translations = require('@assets/i18n/vi.json'); break; @@ -204,6 +223,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-datetimeformat/locale-data/en'); require('@formatjs/intl-listformat/locale-data/en'); require('@formatjs/intl-relativetimeformat/locale-data/en'); + require('@formatjs/intl-displaynames/locale-data/en'); translations = en; break; @@ -222,6 +242,7 @@ function loadChinesePolyfills() { require('@formatjs/intl-datetimeformat/locale-data/zh'); require('@formatjs/intl-listformat/locale-data/zh'); require('@formatjs/intl-relativetimeformat/locale-data/zh'); + require('@formatjs/intl-displaynames/locale-data/zh'); } export function getLocaleFromLanguage(lang: string) { diff --git a/app/queries/servers/channel.test.ts b/app/queries/servers/channel.test.ts index 1f5bce72c..f9f54b87e 100644 --- a/app/queries/servers/channel.test.ts +++ b/app/queries/servers/channel.test.ts @@ -8,6 +8,7 @@ import {of as of$} from 'rxjs'; import {General, Permissions} from '@constants'; import {MM_TABLES} from '@constants/database'; +import DatabaseManager from '@database/manager'; import ServerDataOperator from '@database/operator/server_data_operator'; import EphemeralStore from '@store/ephemeral_store'; import TestHelper from '@test/test_helper'; @@ -63,9 +64,13 @@ import {prepareChannels, queryChannelMembers, queryChannelsForAutocomplete, observeChannelMembers, + queryMyChannelsByChannelIds, + queryMyChannelsWithAutotranslation, + observeChannelAutotranslation, + observeIsChannelAutotranslated, } from './channel'; import {queryRoles} from './role'; -import {getCurrentChannelId, observeCurrentChannelId, observeCurrentUserId} from './system'; +import {getCurrentChannelId, observeConfigBooleanValue, observeCurrentChannelId, observeCurrentUserId} from './system'; import {observeTeammateNameDisplay} from './user'; import type ChannelModel from '@typings/database/models/servers/channel'; @@ -1580,3 +1585,178 @@ describe('Channel Observations', () => { }); }); }); + +describe('queryMyChannelsByChannelIds and queryMyChannelsWithAutotranslation', () => { + const serverUrl = 'channelQueries.test.com'; + let database: Database; + let operator: ServerDataOperator; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + describe('queryMyChannelsByChannelIds', () => { + it('should return myChannels for given ids', async () => { + const channel1 = TestHelper.fakeChannel({id: 'ch1', team_id: 'team1'}); + const channel2 = TestHelper.fakeChannel({id: 'ch2', team_id: 'team1'}); + const myCh1 = TestHelper.fakeChannelMember({id: 'ch1', channel_id: 'ch1'}); + const myCh2 = TestHelper.fakeChannelMember({id: 'ch2', channel_id: 'ch2'}); + await operator.handleChannel({channels: [channel1, channel2], prepareRecordsOnly: false}); + await operator.handleMyChannel({channels: [channel1, channel2], myChannels: [myCh1, myCh2], prepareRecordsOnly: false}); + + const result = queryMyChannelsByChannelIds(database, ['ch1', 'ch2']); + const fetched = await result.fetch(); + + expect(fetched.length).toBe(2); + expect(fetched.map((c) => c.id).sort()).toEqual(['ch1', 'ch2']); + }); + + it('should return empty when no matching ids', async () => { + const result = queryMyChannelsByChannelIds(database, ['nonexistent']); + const fetched = await result.fetch(); + expect(fetched.length).toBe(0); + }); + }); + + describe('queryMyChannelsWithAutotranslation', () => { + it('should return only myChannels with channel autotranslation enabled and user autotranslation not disabled', async () => { + const channel1 = TestHelper.fakeChannel({id: 'ch1', team_id: 'team1', autotranslation: true}); + const channel2 = TestHelper.fakeChannel({id: 'ch2', team_id: 'team1', autotranslation: true}); + const myCh1 = TestHelper.fakeChannelMember({id: 'ch1', channel_id: 'ch1', autotranslation_disabled: false}); + const myCh2 = TestHelper.fakeChannelMember({id: 'ch2', channel_id: 'ch2', autotranslation_disabled: true}); + await operator.handleChannel({channels: [channel1, channel2], prepareRecordsOnly: false}); + await operator.handleMyChannel({channels: [channel1, channel2], myChannels: [myCh1, myCh2], prepareRecordsOnly: false}); + + const result = queryMyChannelsWithAutotranslation(database); + const fetched = await result.fetch(); + + expect(fetched.length).toBe(1); + expect(fetched[0].id).toBe('ch1'); + expect(fetched[0].autotranslationDisabled).toBe(false); + }); + + it('should exclude channels that have autotranslation disabled at channel level', async () => { + const channel1 = TestHelper.fakeChannel({id: 'ch1', team_id: 'team1', autotranslation: false}); + const myCh1 = TestHelper.fakeChannelMember({id: 'ch1', channel_id: 'ch1', autotranslation_disabled: false}); + await operator.handleChannel({channels: [channel1], prepareRecordsOnly: false}); + await operator.handleMyChannel({channels: [channel1], myChannels: [myCh1], prepareRecordsOnly: false}); + + const result = queryMyChannelsWithAutotranslation(database); + const fetched = await result.fetch(); + expect(fetched.length).toBe(0); + }); + + it('should return empty when no myChannels have autotranslation disabled false', async () => { + const channel1 = TestHelper.fakeChannel({id: 'ch1', team_id: 'team1', autotranslation: true}); + const myCh1 = TestHelper.fakeChannelMember({id: 'ch1', channel_id: 'ch1', autotranslation_disabled: true}); + await operator.handleChannel({channels: [channel1], prepareRecordsOnly: false}); + await operator.handleMyChannel({channels: [channel1], myChannels: [myCh1], prepareRecordsOnly: false}); + + const result = queryMyChannelsWithAutotranslation(database); + const fetched = await result.fetch(); + expect(fetched.length).toBe(0); + }); + }); + + describe('observeChannelAutotranslation', () => { + const channelId = 'ch_observe'; + + it('should emit true when EnableAutoTranslation is true and channel has autotranslation', async () => { + jest.mocked(observeConfigBooleanValue).mockReturnValue(of$(true)); + const channel = TestHelper.fakeChannel({id: channelId, team_id: 'team1', autotranslation: true}); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + + const subscriptionNext = jest.fn(); + const result = observeChannelAutotranslation(database, channelId); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalledWith(true); + }); + + it('should emit false when EnableAutoTranslation is false', async () => { + jest.mocked(observeConfigBooleanValue).mockReturnValue(of$(false)); + const channel = TestHelper.fakeChannel({id: channelId, team_id: 'team1', autotranslation: true}); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + + const subscriptionNext = jest.fn(); + const result = observeChannelAutotranslation(database, channelId); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalledWith(false); + }); + + it('should emit false when channel has autotranslation false', async () => { + jest.mocked(observeConfigBooleanValue).mockReturnValue(of$(true)); + const channel = TestHelper.fakeChannel({id: channelId, team_id: 'team1', autotranslation: false}); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + + const subscriptionNext = jest.fn(); + const result = observeChannelAutotranslation(database, channelId); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalledWith(false); + }); + + it('should emit false when channel is not found', async () => { + jest.mocked(observeConfigBooleanValue).mockReturnValue(of$(true)); + const subscriptionNext = jest.fn(); + const result = observeChannelAutotranslation(database, 'nonexistent'); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalledWith(false); + }); + }); + + describe('observeIsChannelAutotranslated', () => { + const channelId = 'ch_is_autotranslated'; + + it('should emit true when config and channel has autotranslation true and myChannel has autotranslation disabled false', async () => { + jest.mocked(observeConfigBooleanValue).mockReturnValue(of$(true)); + const channel = TestHelper.fakeChannel({id: channelId, team_id: 'team1', autotranslation: true}); + const myChannel = TestHelper.fakeChannelMember({id: channelId, channel_id: channelId, autotranslation_disabled: false}); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + await operator.handleMyChannel({channels: [channel], myChannels: [myChannel], prepareRecordsOnly: false}); + + const subscriptionNext = jest.fn(); + const result = observeIsChannelAutotranslated(database, channelId); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalledWith(true); + }); + + it('should emit false when channel autotranslation is false', async () => { + jest.mocked(observeConfigBooleanValue).mockReturnValue(of$(true)); + const channel = TestHelper.fakeChannel({id: channelId, team_id: 'team1', autotranslation: false}); + const myChannel = TestHelper.fakeChannelMember({id: channelId, channel_id: channelId, autotranslation_disabled: false}); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + await operator.handleMyChannel({channels: [channel], myChannels: [myChannel], prepareRecordsOnly: false}); + + const subscriptionNext = jest.fn(); + const result = observeIsChannelAutotranslated(database, channelId); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalledWith(false); + }); + + it('should emit false when myChannel autotranslation disabled is true', async () => { + jest.mocked(observeConfigBooleanValue).mockReturnValue(of$(true)); + const channel = TestHelper.fakeChannel({id: channelId, team_id: 'team1', autotranslation: true}); + const myChannel = TestHelper.fakeChannelMember({id: channelId, channel_id: channelId, autotranslation_disabled: true}); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + await operator.handleMyChannel({channels: [channel], myChannels: [myChannel], prepareRecordsOnly: false}); + + const subscriptionNext = jest.fn(); + const result = observeIsChannelAutotranslated(database, channelId); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalledWith(false); + }); + }); +}); diff --git a/app/queries/servers/channel.ts b/app/queries/servers/channel.ts index 125208945..21b334dbf 100644 --- a/app/queries/servers/channel.ts +++ b/app/queries/servers/channel.ts @@ -5,7 +5,7 @@ import {Database, Model, Q, Query, Relation} from '@nozbe/watermelondb'; import {of as of$, Observable, combineLatest} from 'rxjs'; -import {map as map$, switchMap, distinctUntilChanged, combineLatestWith} from 'rxjs/operators'; +import {map as map$, switchMap, distinctUntilChanged, combineLatestWith, map} from 'rxjs/operators'; import {General, Permissions} from '@constants'; import {MM_TABLES} from '@constants/database'; @@ -88,15 +88,15 @@ const buildChannelInfos = async (database: Database, channels: Channel[]) => { const channelInfos: ChannelInfo[] = []; const channelsQuery = await queryAllChannels(database); - const storedChannelsMap = channelsQuery.reduce>((map, channel) => { - map[channel.id] = channel; - return map; + const storedChannelsMap = channelsQuery.reduce>((acc, channel) => { + acc[channel.id] = channel; + return acc; }, {}); const channelInfosQuery = await queryAllChannelsInfo(database); - const storedChannelInfosMap = channelInfosQuery.reduce>((map, info) => { - map[info.id] = info; - return map; + const storedChannelInfosMap = channelInfosQuery.reduce>((acc, info) => { + acc[info.id] = info; + return acc; }, {}); for (const c of channels) { @@ -285,6 +285,15 @@ export const observeMyChannelRoles = (database: Database, channelId: string) => ); }; +export const observeChannelAutotranslation = (database: Database, channelId: string) => { + const enableAutoTranslation = observeConfigBooleanValue(database, 'EnableAutoTranslation'); + const channel = observeChannel(database, channelId); + return combineLatest([enableAutoTranslation, channel]).pipe( + switchMap(([et, c]) => of$(Boolean(et && c?.autotranslation))), + distinctUntilChanged(), + ); +}; + export const getChannelById = async (database: Database, channelId: string) => { try { const channel = await database.get(CHANNEL).find(channelId); @@ -469,6 +478,19 @@ export const queryMyChannelsByTeam = (database: Database, teamId: string, includ ); }; +export const queryMyChannelsByChannelIds = (database: Database, ids: string[]) => { + return database.get(MY_CHANNEL).query( + Q.where('id', Q.oneOf(ids)), + ); +}; + +export const queryMyChannelsWithAutotranslation = (database: Database) => { + return database.get(MY_CHANNEL).query( + Q.on(CHANNEL, Q.where('autotranslation', Q.eq(true))), + Q.where('autotranslation_disabled', Q.eq(false)), + ); +}; + export const observeChannelInfo = (database: Database, channelId: string) => { return database.get(CHANNEL_INFO).query(Q.where('id', channelId), Q.take(1)).observe().pipe( switchMap((result) => (result.length ? result[0].observe() : of$(undefined))), @@ -773,3 +795,10 @@ export const observeIsReadOnlyChannel = (database: Database, channelId: string) switchMap(([c, u, readOnly]) => of$(isDefaultChannel(c) && !isSystemAdmin(u?.roles || '') && readOnly)), ); }; + +export const observeIsChannelAutotranslated = (database: Database, channelId: string) => { + const enableAutoTranslation = observeConfigBooleanValue(database, 'EnableAutoTranslation'); + const channel = observeChannel(database, channelId); + const myChannel = observeMyChannel(database, channelId); + return combineLatest([enableAutoTranslation, channel, myChannel]).pipe(map(([et, c, mc]) => Boolean(et && c?.autotranslation && !mc?.autotranslationDisabled))); +}; diff --git a/app/queries/servers/entry.ts b/app/queries/servers/entry.ts index 3426248fe..39ffaf804 100644 --- a/app/queries/servers/entry.ts +++ b/app/queries/servers/entry.ts @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {handleAutotranslationChanges} from '@actions/remote/entry/common'; import {MM_TABLES} from '@constants/database'; import DatabaseManager from '@database/manager'; @@ -159,7 +160,7 @@ export async function truncateCrtRelatedTables(serverUrl: string): Promise<{erro * @param {boolean} [args.isCRTEnabled] - Whether Collapsed Reply Threads are enabled * @returns {Promise} Promise that resolves to an array of processed database models */ -export async function processEntryModels({ +export async function processEntryModels(serverUrl: string, { operator, teamData, chData, @@ -167,6 +168,7 @@ export async function processEntryModels({ meData, isCRTEnabled, }: PrepareModelsArgs): Promise { + await handleAutotranslationChanges(serverUrl, meData, chData); const modelPromises = await prepareEntryModels({operator, teamData, chData, prefData, meData, isCRTEnabled}); const flattenModels = (await Promise.all(modelPromises)).flat(); diff --git a/app/queries/servers/user.ts b/app/queries/servers/user.ts index bfb5ac2ef..c7f731029 100644 --- a/app/queries/servers/user.ts +++ b/app/queries/servers/user.ts @@ -150,3 +150,20 @@ export const getUsersFromDMSorted = async (database: Database, memberIds: string return []; } }; + +export const observeIsUserLanguageSupportedByAutotranslation = (database: Database) => { + const currentUser = observeCurrentUser(database); + const autoTranslationLanguages = observeConfigValue(database, 'AutoTranslationLanguages'); + + return combineLatest([currentUser, autoTranslationLanguages]).pipe( + switchMap(([user, languages]) => { + if (!user?.locale || !languages) { + return of$(false); + } + const userLocale = user.locale; + const languagesList = languages.split(','); + return of$(languagesList.includes(userLocale)); + }), + distinctUntilChanged(), + ); +}; diff --git a/app/screens/channel/channel.tsx b/app/screens/channel/channel.tsx index 0d86e1814..3d84da3f1 100644 --- a/app/screens/channel/channel.tsx +++ b/app/screens/channel/channel.tsx @@ -1,14 +1,17 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useMemo, useState} from 'react'; -import {Platform, type LayoutChangeEvent, StyleSheet} from 'react-native'; +import React, {useCallback, useEffect, useState, useMemo} from 'react'; +import {Platform, DeviceEventEmitter, type LayoutChangeEvent, StyleSheet} from 'react-native'; import {KeyboardProvider} from 'react-native-keyboard-controller'; import {type Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'; import {storeLastViewedChannelIdAndServer, removeLastViewedChannelIdAndServer} from '@actions/app/global'; +import {fetchPostsForChannel} from '@actions/remote/post'; import FloatingCallContainer from '@calls/components/floating_call_container'; import FreezeScreen from '@components/freeze_screen'; +import {Events} from '@constants'; +import {useServerUrl} from '@context/server'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import {useChannelSwitch} from '@hooks/channel_switch'; import {useIsTablet} from '@hooks/device'; @@ -75,6 +78,7 @@ const Channel = ({ const switchingChannels = useChannelSwitch(); const defaultHeight = useDefaultHeaderHeight(); const [containerHeight, setContainerHeight] = useState(0); + const serverUrl = useServerUrl(); const shouldRender = !switchingTeam && !switchingChannels && shouldRenderPosts && Boolean(channelId); const isVisible = useIsScreenVisible(componentId); const [isEmojiSearchFocused, setIsEmojiSearchFocused] = useState(false); @@ -95,6 +99,15 @@ const Channel = ({ useAndroidHardwareBackHandler(componentId, handleBack); + useEffect(() => { + const listener = DeviceEventEmitter.addListener(Events.POST_DELETED_FOR_CHANNEL, ({serverUrl: url, channelId: id}) => { + if (serverUrl === url && channelId === id) { + fetchPostsForChannel(serverUrl, channelId, false, true); + } + }); + return () => listener.remove(); + }, [serverUrl, channelId]); + const marginTop = defaultHeight + (isTablet ? 0 : -insets.top); useEffect(() => { // This is done so that the header renders diff --git a/app/screens/channel/header/header.test.tsx b/app/screens/channel/header/header.test.tsx index c0a884d70..752626a08 100644 --- a/app/screens/channel/header/header.test.tsx +++ b/app/screens/channel/header/header.test.tsx @@ -54,6 +54,7 @@ describe('ChannelHeader', () => { isOwnDirectMessage: false, shouldRenderChannelBanner: false, isPlaybooksEnabled: true, + isChannelAutotranslated: false, }; } diff --git a/app/screens/channel/header/header.tsx b/app/screens/channel/header/header.tsx index 7e0752019..b0d182555 100644 --- a/app/screens/channel/header/header.tsx +++ b/app/screens/channel/header/header.tsx @@ -60,6 +60,7 @@ type ChannelProps = { playbooksActiveRuns: number; isPlaybooksEnabled: boolean; activeRunId?: string; + isChannelAutotranslated: boolean; // searchTerm: string; }; @@ -113,6 +114,7 @@ const ChannelHeader = ({ hasPlaybookRuns, isPlaybooksEnabled, activeRunId, + isChannelAutotranslated, }: ChannelProps) => { const intl = useIntl(); const isTablet = useIsTablet(); @@ -315,6 +317,19 @@ const ChannelHeader = ({ return undefined; }, [memberCount, customStatus, isCustomStatusExpired, theme.sidebarHeaderTextColor, styles.customStatusContainer, styles.customStatusEmoji, styles.customStatusText, styles.subtitle, isCustomStatusEnabled]); + const titleCompanion = useMemo(() => { + if (isChannelAutotranslated) { + return ( + + ); + } + return undefined; + }, [isChannelAutotranslated, theme.sidebarHeaderTextColor]); + useEffect(() => { const asyncEffect = async () => { if (isPlaybooksEnabled && !EphemeralStore.getChannelPlaybooksSynced(serverUrl, channelId)) { @@ -338,6 +353,7 @@ const ChannelHeader = ({ subtitle={subtitle} subtitleCompanion={subtitleCompanion} title={title} + titleCompanion={titleCompanion} /> diff --git a/app/screens/channel/header/index.ts b/app/screens/channel/header/index.ts index dbec5df95..f4cdf487a 100644 --- a/app/screens/channel/header/index.ts +++ b/app/screens/channel/header/index.ts @@ -9,10 +9,10 @@ import {combineLatestWith, distinctUntilChanged, switchMap} from 'rxjs/operators import {General} from '@constants'; import {queryPlaybookRunsPerChannel} from '@playbooks/database/queries/run'; import {observeIsPlaybooksEnabled} from '@playbooks/database/queries/version'; -import {observeChannel, observeChannelInfo} from '@queries/servers/channel'; +import {observeChannel, observeChannelInfo, observeIsChannelAutotranslated} from '@queries/servers/channel'; import {observeCanAddBookmarks, queryBookmarks} from '@queries/servers/channel_bookmark'; import {observeConfigBooleanValue, observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system'; -import {observeUser} from '@queries/servers/user'; +import {observeIsUserLanguageSupportedByAutotranslation, observeUser} from '@queries/servers/user'; import { getUserCustomStatus, getUserIdFromChannelName, @@ -63,6 +63,14 @@ const enhanced = withObservables(['channelId'], ({channelId, database}: OwnProps const isCustomStatusEnabled = observeConfigBooleanValue(database, 'EnableCustomUserStatuses'); const isPlaybooksEnabled = observeIsPlaybooksEnabled(database); + const isChannelAutotranslatedBase = observeIsChannelAutotranslated(database, channelId); + const isUserLanguageSupported = observeIsUserLanguageSupportedByAutotranslation(database); + + const isChannelAutotranslated = isChannelAutotranslatedBase.pipe( + combineLatestWith(isUserLanguageSupported), + switchMap(([isAutotranslated, isLanguageSupported]) => of$(isAutotranslated && isLanguageSupported)), + distinctUntilChanged(), + ); // const searchTerm = channel.pipe( // combineLatestWith(dmUser), @@ -116,6 +124,7 @@ const enhanced = withObservables(['channelId'], ({channelId, database}: OwnProps displayName, hasBookmarks, isBookmarksEnabled, + isChannelAutotranslated, isCustomStatusEnabled, isCustomStatusExpired, isOwnDirectMessage, diff --git a/app/screens/channel_info/channel_info.tsx b/app/screens/channel_info/channel_info.tsx index da0ef2475..ec815e26c 100644 --- a/app/screens/channel_info/channel_info.tsx +++ b/app/screens/channel_info/channel_info.tsx @@ -5,9 +5,7 @@ import React, {useCallback} from 'react'; import {ScrollView, View} from 'react-native'; import {type Edge, SafeAreaView} from 'react-native-safe-area-context'; -import ChannelInfoEnableCalls from '@calls/components/channel_info_enable_calls'; import ChannelActions from '@components/channel_actions'; -import ConvertToChannelLabel from '@components/channel_actions/convert_to_channel/convert_to_channel_label'; import ChannelBookmarks from '@components/channel_bookmarks'; import {General} from '@constants'; import {useServerUrl} from '@context/server'; @@ -28,8 +26,6 @@ import type {AvailableScreens} from '@typings/screens/navigation'; type Props = { canAddBookmarks: boolean; - canEnableDisableCalls: boolean; - canManageSettings: boolean; channelId: string; closeButtonId: string; componentId: AvailableScreens; @@ -38,10 +34,10 @@ type Props = { isPlaybooksEnabled: boolean; groupCallsAllowed: boolean; canManageMembers: boolean; - isConvertGMFeatureAvailable: boolean; isCRTEnabled: boolean; - isGuestUser: boolean; type?: ChannelType; + hasChannelSettingsActions: boolean; + isAutotranslationEnabledForThisChannel: boolean; } const edges: Edge[] = ['bottom', 'left', 'right']; @@ -63,9 +59,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ const ChannelInfo = ({ canAddBookmarks, - canEnableDisableCalls, canManageMembers, - canManageSettings, channelId, closeButtonId, componentId, @@ -73,10 +67,10 @@ const ChannelInfo = ({ isCallsEnabledInChannel, isPlaybooksEnabled, groupCallsAllowed, - isConvertGMFeatureAvailable, isCRTEnabled, - isGuestUser, type, + hasChannelSettingsActions, + isAutotranslationEnabledForThisChannel, }: Props) => { const theme = useTheme(); const serverUrl = useServerUrl(); @@ -96,8 +90,6 @@ const ChannelInfo = ({ useNavButtonPressed(closeButtonId, componentId, onPressed, [onPressed]); useAndroidHardwareBackHandler(componentId, onPressed); - const convertGMOptionAvailable = isConvertGMFeatureAvailable && type === General.GM_CHANNEL && !isGuestUser; - return ( - {convertGMOptionAvailable && - <> - - - - } - {canEnableDisableCalls && - <> - - - - } diff --git a/app/screens/channel_info/destructive_options/archive/index.ts b/app/screens/channel_info/destructive_options/archive/index.ts deleted file mode 100644 index 86b02a69f..000000000 --- a/app/screens/channel_info/destructive_options/archive/index.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; -import {of as of$} from 'rxjs'; -import {combineLatestWith, switchMap} from 'rxjs/operators'; - -import {General, Permissions} from '@constants'; -import {observeChannel} from '@queries/servers/channel'; -import {observePermissionForChannel, observePermissionForTeam} from '@queries/servers/role'; -import {observeConfigBooleanValue} from '@queries/servers/system'; -import {observeCurrentTeam} from '@queries/servers/team'; -import {observeCurrentUser} from '@queries/servers/user'; -import {isDefaultChannel} from '@utils/channel'; - -import Archive from './archive'; - -import type {WithDatabaseArgs} from '@typings/database/database'; - -type Props = WithDatabaseArgs & { - channelId: string; - type?: string; -} - -const enhanced = withObservables(['channelId', 'type'], ({channelId, database, type}: Props) => { - const team = observeCurrentTeam(database); - const currentUser = observeCurrentUser(database); - const channel = observeChannel(database, channelId); - const canViewArchivedChannels = observeConfigBooleanValue(database, 'ExperimentalViewArchivedChannels'); - const isArchived = channel.pipe(switchMap((c) => of$((c?.deleteAt || 0) > 0))); - const canLeave = channel.pipe( - combineLatestWith(currentUser), - switchMap(([ch, u]) => { - const isDC = isDefaultChannel(ch); - return of$(!isDC || (isDC && u?.isGuest)); - }), - ); - - const canArchive = channel.pipe( - combineLatestWith(currentUser, canLeave, isArchived), - switchMap(([ch, u, leave, archived]) => { - if ( - type === General.DM_CHANNEL || type === General.GM_CHANNEL || - !ch || !u || !leave || archived - ) { - return of$(false); - } - - if (type === General.OPEN_CHANNEL) { - return observePermissionForChannel(database, ch, u, Permissions.DELETE_PUBLIC_CHANNEL, true); - } - - return observePermissionForChannel(database, ch, u, Permissions.DELETE_PRIVATE_CHANNEL, true); - }), - ); - - const canUnarchive = team.pipe( - combineLatestWith(currentUser, isArchived), - switchMap(([t, u, archived]) => { - if ( - type === General.DM_CHANNEL || type === General.GM_CHANNEL || - !t || !u || !archived - ) { - return of$(false); - } - - return observePermissionForTeam(database, t, u, Permissions.MANAGE_TEAM, false); - }), - ); - - return { - canArchive, - canUnarchive, - canViewArchivedChannels, - displayName: channel.pipe(switchMap((c) => of$(c?.displayName))), - }; -}); - -export default withDatabase(enhanced(Archive)); diff --git a/app/screens/channel_info/destructive_options/convert_private/index.ts b/app/screens/channel_info/destructive_options/convert_private/index.ts deleted file mode 100644 index 1868a9e7e..000000000 --- a/app/screens/channel_info/destructive_options/convert_private/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; -import {of as of$} from 'rxjs'; -import {combineLatestWith, switchMap} from 'rxjs/operators'; - -import {Permissions} from '@constants'; -import {observeChannel} from '@queries/servers/channel'; -import {observePermissionForChannel} from '@queries/servers/role'; -import {observeCurrentUser} from '@queries/servers/user'; -import {isDefaultChannel} from '@utils/channel'; - -import ConvertPrivate from './convert_private'; - -import type {WithDatabaseArgs} from '@typings/database/database'; - -type Props = WithDatabaseArgs & { - channelId: string; -} - -const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => { - const currentUser = observeCurrentUser(database); - const channel = observeChannel(database, channelId); - const canConvert = channel.pipe( - combineLatestWith(currentUser), - switchMap(([ch, u]) => { - if (!ch || !u || isDefaultChannel(ch)) { - return of$(false); - } - - return observePermissionForChannel(database, ch, u, Permissions.CONVERT_PUBLIC_CHANNEL_TO_PRIVATE, false); - }), - ); - - return { - canConvert, - displayName: channel.pipe(switchMap((c) => of$(c?.displayName))), - }; -}); - -export default withDatabase(enhanced(ConvertPrivate)); diff --git a/app/screens/channel_info/destructive_options/index.tsx b/app/screens/channel_info/destructive_options/index.tsx index edde7af11..f80352cd4 100644 --- a/app/screens/channel_info/destructive_options/index.tsx +++ b/app/screens/channel_info/destructive_options/index.tsx @@ -4,37 +4,19 @@ import React from 'react'; import LeaveChannelLabel from '@components/channel_actions/leave_channel_label'; -import {General} from '@constants'; - -import Archive from './archive'; -import ConvertPrivate from './convert_private'; - -import type {AvailableScreens} from '@typings/screens/navigation'; type Props = { channelId: string; - componentId: AvailableScreens; - type?: ChannelType; } -const DestructiveOptions = ({channelId, componentId, type}: Props) => { +const DestructiveOptions = ({channelId}: Props) => { return ( <> - {type === General.OPEN_CHANNEL && - - } - {type !== General.DM_CHANNEL && type !== General.GM_CHANNEL && - - } ); }; diff --git a/app/screens/channel_info/index.ts b/app/screens/channel_info/index.ts index b3a7aa8ec..f8beb4288 100644 --- a/app/screens/channel_info/index.ts +++ b/app/screens/channel_info/index.ts @@ -2,16 +2,17 @@ // See LICENSE.txt for license information. import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; -import {combineLatest, of as of$} from 'rxjs'; +import {combineLatest, Observable, of as of$} from 'rxjs'; import {distinctUntilChanged, switchMap, combineLatestWith} from 'rxjs/operators'; import {observeIsCallsEnabledInChannel} from '@calls/observers'; import {observeCallsConfig} from '@calls/state'; +import {General, Permissions} from '@constants'; import {withServerUrl} from '@context/server'; import {observeIsPlaybooksEnabled} from '@playbooks/database/queries/version'; -import {observeCurrentChannel} from '@queries/servers/channel'; +import {observeChannelAutotranslation, observeCurrentChannel} from '@queries/servers/channel'; import {observeCanAddBookmarks} from '@queries/servers/channel_bookmark'; -import {observeCanManageChannelMembers, observeCanManageChannelSettings} from '@queries/servers/role'; +import {observeCanManageChannelMembers, observeCanManageChannelSettings, observePermissionForChannel, observePermissionForTeam} from '@queries/servers/role'; import { observeConfigBooleanValue, observeConfigValue, @@ -19,27 +20,34 @@ import { observeCurrentTeamId, observeCurrentUserId, } from '@queries/servers/system'; +import {observeCurrentTeam} from '@queries/servers/team'; import {observeIsCRTEnabled} from '@queries/servers/thread'; import {observeCurrentUser, observeUserIsChannelAdmin, observeUserIsTeamAdmin} from '@queries/servers/user'; -import {isTypeDMorGM} from '@utils/channel'; +import {isTypeDMorGM, isDefaultChannel} from '@utils/channel'; import {isMinimumServerVersion} from '@utils/helpers'; import {isSystemAdmin} from '@utils/user'; import ChannelInfo from './channel_info'; +import type {Database} from '@nozbe/watermelondb'; import type {WithDatabaseArgs} from '@typings/database/database'; +import type ChannelModel from '@typings/database/models/servers/channel'; +import type UserModel from '@typings/database/models/servers/user'; type Props = WithDatabaseArgs & { serverUrl: string; } -const enhanced = withObservables([], ({serverUrl, database}: Props) => { - const channel = observeCurrentChannel(database); - const type = channel.pipe(switchMap((c) => of$(c?.type))); - const channelId = channel.pipe(switchMap((c) => of$(c?.id || ''))); +const observeHasChannelSettingsActions = ( + database: Database, + serverUrl: string, + channelId: Observable, + channel: Observable, + currentUser: Observable, + type: Observable, +) => { const teamId = channel.pipe(switchMap((c) => (c?.teamId ? of$(c.teamId) : observeCurrentTeamId(database)))); const userId = observeCurrentUserId(database); - const currentUser = observeCurrentUser(database); const isTeamAdmin = combineLatest([teamId, userId]).pipe( switchMap(([tId, uId]) => observeUserIsTeamAdmin(database, uId, tId)), ); @@ -72,6 +80,7 @@ const enhanced = withObservables([], ({serverUrl, database}: Props) => { switchMap((v) => of$(isMinimumServerVersion(v || '', 7, 6))), ); const dmOrGM = type.pipe(switchMap((t) => of$(isTypeDMorGM(t)))); + const canEnableDisableCalls = combineLatest([callsPluginEnabled, callsDefaultEnabled, allowEnableCalls, systemAdmin, channelAdmin, callsGAServer, dmOrGM, isTeamAdmin]).pipe( switchMap(([pluginEnabled, liveMode, allow, sysAdmin, chAdmin, gaServer, dmGM, tAdmin]) => { // Always false if the plugin is not enabled. @@ -112,17 +121,6 @@ const enhanced = withObservables([], ({serverUrl, database}: Props) => { return of$(false); }), ); - const isCallsEnabledInChannel = observeIsCallsEnabledInChannel(database, serverUrl, observeCurrentChannelId(database)); - const groupCallsAllowed = observeCallsConfig(serverUrl).pipe( - switchMap((config) => of$(config.GroupCallsAllowed)), - distinctUntilChanged(), - ); - - const canManageMembers = currentUser.pipe( - combineLatestWith(channelId), - switchMap(([u, cId]) => (u ? observeCanManageChannelMembers(database, cId, u) : of$(false))), - distinctUntilChanged(), - ); const canManageSettings = currentUser.pipe( combineLatestWith(channelId), @@ -139,6 +137,107 @@ const enhanced = withObservables([], ({serverUrl, database}: Props) => { switchMap((version) => of$(isMinimumServerVersion(version || '', 9, 1))), ); + const isChannelAutotranslateEnabled = observeConfigBooleanValue(database, 'EnableAutoTranslation'); + + const team = observeCurrentTeam(database); + const isArchived = channel.pipe(switchMap((c) => of$((c?.deleteAt || 0) > 0))); + const canLeave = channel.pipe( + combineLatestWith(currentUser), + switchMap(([ch, u]) => { + const isDC = isDefaultChannel(ch); + return of$(!isDC || (isDC && u?.isGuest)); + }), + ); + + const canConvert = channel.pipe( + combineLatestWith(currentUser), + switchMap(([ch, u]) => { + if (!ch || !u || isDefaultChannel(ch)) { + return of$(false); + } + if (ch.type !== General.OPEN_CHANNEL) { + return of$(false); + } + return observePermissionForChannel(database, ch, u, Permissions.CONVERT_PUBLIC_CHANNEL_TO_PRIVATE, false); + }), + ); + + const canArchive = channel.pipe( + combineLatestWith(currentUser, canLeave, isArchived, type), + switchMap(([ch, u, leave, archived, chType]) => { + if ( + chType === General.DM_CHANNEL || chType === General.GM_CHANNEL || + !ch || !u || !leave || archived + ) { + return of$(false); + } + + if (chType === General.OPEN_CHANNEL) { + return observePermissionForChannel(database, ch, u, Permissions.DELETE_PUBLIC_CHANNEL, true); + } + + return observePermissionForChannel(database, ch, u, Permissions.DELETE_PRIVATE_CHANNEL, true); + }), + ); + + const canUnarchive = team.pipe( + combineLatestWith(currentUser, isArchived, type), + switchMap(([t, u, archived, chType]) => { + if ( + chType === General.DM_CHANNEL || chType === General.GM_CHANNEL || + !t || !u || !archived + ) { + return of$(false); + } + + return observePermissionForTeam(database, t, u, Permissions.MANAGE_TEAM, false); + }), + ); + + const convertGMOptionAvailable = combineLatest([isConvertGMFeatureAvailable, type, isGuestUser]).pipe( + switchMap(([available, chType, guest]) => of$(available && chType === General.GM_CHANNEL && !guest)), + ); + + // Check if any channel_settings action is available + const hasChannelSettingsActions = combineLatest([ + canManageSettings, + canConvert, + canArchive, + canUnarchive, + canEnableDisableCalls, + convertGMOptionAvailable, + isChannelAutotranslateEnabled, + ]).pipe( + switchMap(([manageSettings, convert, archive, unarchive, enableCalls, convertGM, autotranslateEnabled]) => { + return of$( + manageSettings || // Channel info or Channel autotranslations + convert || // Convert to private + archive || unarchive || // Archive channel + enableCalls || // Enable/Disable calls + convertGM || // Convert GM to channel + (manageSettings && autotranslateEnabled), // Channel autotranslations + ); + }), + ); + + return hasChannelSettingsActions; +}; + +const enhanced = withObservables([], ({serverUrl, database}: Props) => { + const channel = observeCurrentChannel(database); + const type = channel.pipe(switchMap((c) => of$(c?.type))); + const channelId = channel.pipe(switchMap((c) => of$(c?.id || ''))); + + const currentUser = observeCurrentUser(database); + + const isCallsEnabledInChannel = observeIsCallsEnabledInChannel(database, serverUrl, observeCurrentChannelId(database)); + + const canManageMembers = currentUser.pipe( + combineLatestWith(channelId), + switchMap(([u, cId]) => (u ? observeCanManageChannelMembers(database, cId, u) : of$(false))), + distinctUntilChanged(), + ); + const isBookmarksEnabled = observeConfigBooleanValue(database, 'FeatureFlagChannelBookmarks'); const canAddBookmarks = channelId.pipe( @@ -147,21 +246,21 @@ const enhanced = withObservables([], ({serverUrl, database}: Props) => { }), ); - const isPlaybooksEnabled = observeIsPlaybooksEnabled(database); + const isAutotranslationEnabledForThisChannel = channelId.pipe( + switchMap((cId) => observeChannelAutotranslation(database, cId)), + ); + const isPlaybooksEnabled = observeIsPlaybooksEnabled(database); return { type, - canEnableDisableCalls, isCallsEnabledInChannel, - groupCallsAllowed, canAddBookmarks, canManageMembers, - canManageSettings, isBookmarksEnabled, isCRTEnabled: observeIsCRTEnabled(database), - isGuestUser, - isConvertGMFeatureAvailable, isPlaybooksEnabled, + hasChannelSettingsActions: observeHasChannelSettingsActions(database, serverUrl, channelId, channel, currentUser, type), + isAutotranslationEnabledForThisChannel, }; }); diff --git a/app/screens/channel_info/options/channel_settings/index.tsx b/app/screens/channel_info/options/channel_settings/index.tsx new file mode 100644 index 000000000..835c48c59 --- /dev/null +++ b/app/screens/channel_info/options/channel_settings/index.tsx @@ -0,0 +1,37 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; +import {Platform} from 'react-native'; + +import OptionItem from '@components/option_item'; +import {Screens} from '@constants'; +import {usePreventDoubleTap} from '@hooks/utils'; +import {goToScreen} from '@screens/navigation'; + +type Props = { + channelId: string; +} + +const ChannelSettings = ({channelId}: Props) => { + const {formatMessage} = useIntl(); + const title = formatMessage({id: 'channel_info.channel_settings', defaultMessage: 'Channel Settings'}); + + const goToChannelSettings = usePreventDoubleTap(useCallback(async () => { + goToScreen(Screens.CHANNEL_SETTINGS, title, {channelId}); + }, [channelId, title])); + + return ( + + ); +}; + +export default ChannelSettings; + diff --git a/app/screens/channel_info/options/index.test.tsx b/app/screens/channel_info/options/index.test.tsx index 954f6d2d9..93bf22c52 100644 --- a/app/screens/channel_info/options/index.test.tsx +++ b/app/screens/channel_info/options/index.test.tsx @@ -8,6 +8,8 @@ import DatabaseManager from '@database/manager'; import PlaybookRunsOption from '@playbooks/components/channel_actions/playbook_runs_option'; import {renderWithEverything} from '@test/intl-test-helper'; +import MyAutotranslation from './my_autotranslation'; + import ChannelInfoOptions from './'; import type {Database} from '@nozbe/watermelondb'; @@ -19,6 +21,11 @@ jest.mocked(PlaybookRunsOption).mockImplementation((props) => { return React.createElement('PlaybookRunsOption', {...props, testID: 'playbook-runs-option'}); }); +jest.mock('./my_autotranslation'); +jest.mocked(MyAutotranslation).mockImplementation((props) => { + return React.createElement('MyAutotranslation', {...props, testID: 'my-autotranslation-option'}); +}); + describe('ChannelInfoOptions', () => { let database: Database; @@ -28,8 +35,9 @@ describe('ChannelInfoOptions', () => { callsEnabled: false, canManageMembers: false, isCRTEnabled: false, - canManageSettings: false, isPlaybooksEnabled: true, + hasChannelSettingsActions: false, + isAutotranslationEnabledForThisChannel: true, }; } beforeEach(async () => { @@ -57,4 +65,16 @@ describe('ChannelInfoOptions', () => { rerender(); expect(queryByTestId('playbook-runs-option')).toBeNull(); }); + it('should not show my autotranslation option when isAutotranslationEnabledForThisChannel is false', () => { + const props = getBaseProps(); + props.isAutotranslationEnabledForThisChannel = false; + const {queryByTestId} = renderWithEverything(, {database}); + expect(queryByTestId('my-autotranslation-option')).toBeNull(); + }); + it('should show my autotranslation option when isAutotranslationEnabledForThisChannel is true', () => { + const props = getBaseProps(); + props.isAutotranslationEnabledForThisChannel = true; + const {getByTestId} = renderWithEverything(, {database}); + expect(getByTestId('my-autotranslation-option')).toBeTruthy(); + }); }); diff --git a/app/screens/channel_info/options/index.tsx b/app/screens/channel_info/options/index.tsx index 924142aaa..4e3cbdbf5 100644 --- a/app/screens/channel_info/options/index.tsx +++ b/app/screens/channel_info/options/index.tsx @@ -11,9 +11,10 @@ import {isTypeDMorGM} from '@utils/channel'; import AddMembers from './add_members'; import AutoFollowThreads from './auto_follow_threads'; import ChannelFiles from './channel_files'; -import EditChannel from './edit_channel'; +import ChannelSettings from './channel_settings'; import IgnoreMentions from './ignore_mentions'; import Members from './members'; +import MyAutotranslation from './my_autotranslation'; import NotificationPreference from './notification_preference'; import PinnedMessages from './pinned_messages'; @@ -24,7 +25,8 @@ type Props = { canManageMembers: boolean; isCRTEnabled: boolean; isPlaybooksEnabled: boolean; - canManageSettings: boolean; + hasChannelSettingsActions: boolean; + isAutotranslationEnabledForThisChannel: boolean; } const Options = ({ @@ -34,12 +36,16 @@ const Options = ({ canManageMembers, isCRTEnabled, isPlaybooksEnabled, - canManageSettings, + hasChannelSettingsActions, + isAutotranslationEnabledForThisChannel, }: Props) => { const isDMorGM = isTypeDMorGM(type); return ( <> + {hasChannelSettingsActions && ( + + )} {type !== General.DM_CHANNEL && ( <> {isCRTEnabled && ( @@ -49,6 +55,9 @@ const Options = ({ )} + {isAutotranslationEnabledForThisChannel && ( + + )} {isPlaybooksEnabled && !isDMorGM && @@ -69,9 +78,6 @@ const Options = ({ testID='channel_info.options.copy_channel_link.option' /> } - {canManageSettings && - - } ); }; diff --git a/app/screens/channel_info/options/my_autotranslation/index.ts b/app/screens/channel_info/options/my_autotranslation/index.ts new file mode 100644 index 000000000..ea5841c57 --- /dev/null +++ b/app/screens/channel_info/options/my_autotranslation/index.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {observeChannel, observeIsChannelAutotranslated} from '@queries/servers/channel'; +import {observeIsUserLanguageSupportedByAutotranslation} from '@queries/servers/user'; + +import MyAutotranslation from './my_autotranslation'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type Props = WithDatabaseArgs & { + channelId: string; +} + +const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => { + const channel = observeChannel(database, channelId); + const enabled = observeIsChannelAutotranslated(database, channelId); + const isLanguageSupported = observeIsUserLanguageSupportedByAutotranslation(database); + + return { + enabled, + displayName: channel.pipe(switchMap((c) => of$(c?.displayName || ''))), + isLanguageSupported, + }; +}); + +export default withDatabase(enhanced(MyAutotranslation)); + diff --git a/app/screens/channel_info/options/my_autotranslation/my_autotranslation.test.tsx b/app/screens/channel_info/options/my_autotranslation/my_autotranslation.test.tsx new file mode 100644 index 000000000..73f3f0b3a --- /dev/null +++ b/app/screens/channel_info/options/my_autotranslation/my_autotranslation.test.tsx @@ -0,0 +1,132 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Alert} from 'react-native'; + +import {setMyChannelAutotranslation} from '@actions/remote/channel'; +import DatabaseManager from '@database/manager'; +import {act, fireEvent, renderWithEverything, waitFor} from '@test/intl-test-helper'; + +import MyAutotranslation from './my_autotranslation'; + +const serverUrl = 'my_autotranslation.test.com'; + +jest.mock('@context/server', () => ({ + useServerUrl: jest.fn().mockReturnValue(serverUrl), +})); +jest.mock('@actions/remote/channel', () => ({ + setMyChannelAutotranslation: jest.fn(), +})); +jest.mock('@hooks/utils', () => ({ + usePreventDoubleTap: (fn: () => void) => fn, +})); +jest.mock('react-native/Libraries/Alert/Alert', () => ({ + alert: jest.fn(), +})); + +describe('MyAutotranslation', () => { + let database: import('@nozbe/watermelondb').Database; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database; + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + jest.clearAllMocks(); + }); + + it('renders disabled option when language is not supported', () => { + const {getByTestId, getByText} = renderWithEverything( + , + {database}, + ); + expect(getByTestId('channel_info.options.my_autotranslation.option')).toBeTruthy(); + expect(getByText('Your language is not supported')).toBeTruthy(); + }); + + it('renders toggle with correct testID when enabled', () => { + const {getByTestId} = renderWithEverything( + , + {database}, + ); + expect(getByTestId('channel_info.options.my_autotranslation.option.toggled.true')).toBeTruthy(); + }); + + it('renders toggle with correct testID when disabled', () => { + const {getByTestId} = renderWithEverything( + , + {database}, + ); + expect(getByTestId('channel_info.options.my_autotranslation.option.toggled.false')).toBeTruthy(); + }); + + it('calls setMyChannelAutotranslation when toggling from off to on', async () => { + jest.mocked(setMyChannelAutotranslation).mockResolvedValue({data: true}); + const {getByTestId} = renderWithEverything( + , + {database}, + ); + const toggle = getByTestId('channel_info.options.my_autotranslation.option.toggled.false.toggled.false.button'); + await act(async () => { + fireEvent(toggle, 'valueChange', true); + }); + await waitFor(() => { + expect(setMyChannelAutotranslation).toHaveBeenCalledWith(serverUrl, 'channel1', true); + }); + }); + + it('calls alert when toggling from on to off', async () => { + jest.mocked(setMyChannelAutotranslation).mockResolvedValue({data: false}); + const {getByTestId} = renderWithEverything( + , + {database}, + ); + const toggle = getByTestId('channel_info.options.my_autotranslation.option.toggled.true.toggled.true.button'); + await act(async () => { + fireEvent(toggle, 'valueChange', false); + }); + await waitFor(() => { + expect(Alert.alert).toHaveBeenCalledWith('Turn off auto-translation', 'Messages in this channel will revert to their original language. This will only affect how you see this channel. Other members won’t be affected.', [ + {text: 'cancel', style: 'cancel'}, + {text: 'Yes, turn off', onPress: expect.any(Function)}, + ]); + expect(setMyChannelAutotranslation).not.toHaveBeenCalled(); + }); + + const yesButton = jest.mocked(Alert.alert).mock.calls[0][2]?.[1]; + await act(async () => { + yesButton?.onPress?.(); + }); + await waitFor(() => { + expect(setMyChannelAutotranslation).toHaveBeenCalledWith(serverUrl, 'channel1', false); + }); + }); +}); diff --git a/app/screens/channel_info/options/my_autotranslation/my_autotranslation.tsx b/app/screens/channel_info/options/my_autotranslation/my_autotranslation.tsx new file mode 100644 index 000000000..19f37297a --- /dev/null +++ b/app/screens/channel_info/options/my_autotranslation/my_autotranslation.tsx @@ -0,0 +1,126 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useState} from 'react'; +import {defineMessages, useIntl} from 'react-intl'; +import {Alert} from 'react-native'; + +import {setMyChannelAutotranslation} from '@actions/remote/channel'; +import OptionItem from '@components/option_item'; +import {useServerUrl} from '@context/server'; +import {usePreventDoubleTap} from '@hooks/utils'; +import {alertErrorWithFallback} from '@utils/draft'; + +const messages = defineMessages({ + label: { + id: 'channel_info.my_autotranslation', + defaultMessage: 'Auto-translation', + }, + failed: { + id: 'channel_info.my_autotranslation_failed', + defaultMessage: 'An error occurred trying to enable automatic translation for yourself in channel {displayName}', + }, + languageNotSupported: { + id: 'channel_info.my_autotranslation_language_not_supported', + defaultMessage: 'Your language is not supported', + }, + enabled: { + id: 'channel_info.my_autotranslation_enabled', + defaultMessage: 'On ({language})', + }, + disabled: { + id: 'channel_info.my_autotranslation_disabled', + defaultMessage: 'Off', + }, + turnOffTitle: { + id: 'channel_info.turn_off_auto_translation.title', + defaultMessage: 'Turn off auto-translation', + }, + turnOffDescription: { + id: 'channel_info.turn_off_auto_translation.description', + defaultMessage: "Messages in this channel will revert to their original language. This will only affect how you see this channel. Other members won't be affected.", + }, + turnOffCancel: { + id: 'channel_info.turn_off_auto_translation.button.cancel', + defaultMessage: 'cancel', + }, + turnOffYes: { + id: 'channel_info.turn_off_auto_translation.button.yes', + defaultMessage: 'Yes, turn off', + }, +}); + +type Props = { + channelId: string; + enabled: boolean; + displayName: string; + isLanguageSupported: boolean; +}; + +const MyAutotranslation = ({channelId, displayName, enabled, isLanguageSupported}: Props) => { + // Use the local state for optimistic updates + const [autotranslation, setAutotranslation] = useState(enabled); + const serverUrl = useServerUrl(); + const intl = useIntl(); + + const doToggleAutotranslation = useCallback(async () => { + setAutotranslation((v) => !v); + const result = await setMyChannelAutotranslation(serverUrl, channelId, !enabled); + if (result?.error) { + alertErrorWithFallback( + intl, + result.error, + messages.failed, + {displayName}, + ); + setAutotranslation((v) => !v); + } + }, [channelId, displayName, enabled, intl, serverUrl]); + + const toggleAutotranslation = usePreventDoubleTap(useCallback(async () => { + if (autotranslation) { + Alert.alert( + intl.formatMessage(messages.turnOffTitle), + intl.formatMessage(messages.turnOffDescription), + [ + {text: intl.formatMessage(messages.turnOffCancel), style: 'cancel'}, + {text: intl.formatMessage(messages.turnOffYes), onPress: () => doToggleAutotranslation()}, + ], + ); + } else { + doToggleAutotranslation(); + } + }, [autotranslation, doToggleAutotranslation, intl])); + + if (!isLanguageSupported) { + return ( + + ); + } + + const description = autotranslation ? intl.formatMessage(messages.enabled, { + language: intl.formatDisplayName(intl.locale, {type: 'language'}), + }) : intl.formatMessage(messages.disabled); + + return ( + + ); +}; + +export default MyAutotranslation; + diff --git a/app/screens/channel_info/destructive_options/archive/archive.tsx b/app/screens/channel_settings/archive/archive.tsx similarity index 97% rename from app/screens/channel_info/destructive_options/archive/archive.tsx rename to app/screens/channel_settings/archive/archive.tsx index 64758c888..3ebda623e 100644 --- a/app/screens/channel_info/destructive_options/archive/archive.tsx +++ b/app/screens/channel_settings/archive/archive.tsx @@ -159,7 +159,7 @@ const Archive = ({ icon='archive-arrow-up-outline' destructive={true} type='default' - testID='channel_info.options.unarchive_channel.option' + testID='channel_settings.unarchive_channel.option' /> ); } @@ -171,9 +171,10 @@ const Archive = ({ icon='archive-outline' destructive={true} type='default' - testID='channel_info.options.archive_channel.option' + testID='channel_settings.archive_channel.option' /> ); }; export default Archive; + diff --git a/app/screens/channel_settings/archive/index.ts b/app/screens/channel_settings/archive/index.ts new file mode 100644 index 000000000..bffe14ba1 --- /dev/null +++ b/app/screens/channel_settings/archive/index.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {observeChannel} from '@queries/servers/channel'; +import {observeConfigBooleanValue} from '@queries/servers/system'; + +import Archive from './archive'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type Props = WithDatabaseArgs & { + channelId: string; +} + +const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => { + const channel = observeChannel(database, channelId); + const canViewArchivedChannels = observeConfigBooleanValue(database, 'ExperimentalViewArchivedChannels'); + + return { + canViewArchivedChannels, + displayName: channel.pipe(switchMap((c) => of$(c?.displayName))), + }; +}); + +export default withDatabase(enhanced(Archive)); diff --git a/app/screens/channel_settings/channel_autotranslation/channel_autotranslation.test.tsx b/app/screens/channel_settings/channel_autotranslation/channel_autotranslation.test.tsx new file mode 100644 index 000000000..8de4a5e08 --- /dev/null +++ b/app/screens/channel_settings/channel_autotranslation/channel_autotranslation.test.tsx @@ -0,0 +1,97 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Alert} from 'react-native'; + +import {setChannelAutotranslation} from '@actions/remote/channel'; +import DatabaseManager from '@database/manager'; +import {fireEvent, renderWithEverything, waitFor} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import ChannelAutotranslation from './channel_autotranslation'; + +const serverUrl = 'channel_autotranslation.test.com'; + +jest.mock('@context/server', () => ({ + useServerUrl: jest.fn().mockReturnValue(serverUrl), +})); +jest.mock('@actions/remote/channel', () => ({ + setChannelAutotranslation: jest.fn(), +})); +jest.mock('@hooks/utils', () => ({ + usePreventDoubleTap: (fn: () => void) => fn, +})); + +describe('ChannelAutotranslation', () => { + let database: import('@nozbe/watermelondb').Database; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database; + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + jest.clearAllMocks(); + }); + + it('renders toggle with correct testID when enabled', () => { + const {getByTestId} = renderWithEverything( + , + {database}, + ); + expect(getByTestId('channel_settings.channel_autotranslation.option.toggled.true')).toBeTruthy(); + }); + + it('renders toggle with correct testID when disabled', () => { + const {getByTestId} = renderWithEverything( + , + {database}, + ); + expect(getByTestId('channel_settings.channel_autotranslation.option.toggled.false')).toBeTruthy(); + }); + + it('calls setChannelAutotranslation when toggling from off to on', async () => { + jest.mocked(setChannelAutotranslation).mockResolvedValue({channel: TestHelper.fakeChannel({id: 'channel1'})}); + const {getByTestId} = renderWithEverything( + , + {database}, + ); + const toggle = getByTestId('channel_settings.channel_autotranslation.option.toggled.false.toggled.false.button'); + fireEvent(toggle, 'valueChange', true); + await waitFor(() => { + expect(setChannelAutotranslation).toHaveBeenCalledWith(serverUrl, 'channel1', true); + }); + }); + + it('calls alert when setting autotranslation fails', async () => { + jest.mocked(setChannelAutotranslation).mockResolvedValue({error: 'error'}); + const {getByTestId} = renderWithEverything( + , + {database}, + ); + const toggle = getByTestId('channel_settings.channel_autotranslation.option.toggled.true.toggled.true.button'); + fireEvent(toggle, 'valueChange', false); + await waitFor(() => { + expect(Alert.alert).toHaveBeenCalledWith('', 'An error occurred trying to enable automatic translation for channel Test Channel', undefined); + expect(getByTestId('channel_settings.channel_autotranslation.option.toggled.true.toggled.true.button')).toBeTruthy(); + }); + }); +}); diff --git a/app/screens/channel_settings/channel_autotranslation/channel_autotranslation.tsx b/app/screens/channel_settings/channel_autotranslation/channel_autotranslation.tsx new file mode 100644 index 000000000..21c892d03 --- /dev/null +++ b/app/screens/channel_settings/channel_autotranslation/channel_autotranslation.tsx @@ -0,0 +1,61 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useState} from 'react'; +import {defineMessage, useIntl} from 'react-intl'; + +import {setChannelAutotranslation} from '@actions/remote/channel'; +import OptionItem from '@components/option_item'; +import {useServerUrl} from '@context/server'; +import {usePreventDoubleTap} from '@hooks/utils'; +import {alertErrorWithFallback} from '@utils/draft'; + +type Props = { + channelId: string; + enabled: boolean; + displayName: string; +} + +const ChannelAutotranslation = ({channelId, displayName, enabled}: Props) => { + const [autotranslation, setAutotranslation] = useState(enabled); + const serverUrl = useServerUrl(); + const intl = useIntl(); + + const toggleAutotranslation = usePreventDoubleTap(useCallback(async () => { + setAutotranslation((v) => !v); + const result = await setChannelAutotranslation(serverUrl, channelId, !enabled); + if (result?.error) { + alertErrorWithFallback( + intl, + result.error, + defineMessage({ + id: 'channel_settings.channel_autotranslation_failed', + defaultMessage: 'An error occurred trying to enable automatic translation for channel {displayName}', + }), + {displayName}, + ); + setAutotranslation((v) => !v); + } + }, [channelId, displayName, enabled, intl, serverUrl])); + + return ( + + ); +}; + +export default ChannelAutotranslation; + diff --git a/app/screens/channel_settings/channel_autotranslation/index.ts b/app/screens/channel_settings/channel_autotranslation/index.ts new file mode 100644 index 000000000..1476cb6db --- /dev/null +++ b/app/screens/channel_settings/channel_autotranslation/index.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {observeChannel, observeChannelAutotranslation} from '@queries/servers/channel'; + +import ChannelAutotranslation from './channel_autotranslation'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type Props = WithDatabaseArgs & { + channelId: string; +} + +const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => { + const channel = observeChannel(database, channelId); + const enabled = observeChannelAutotranslation(database, channelId); + + return { + enabled, + displayName: channel.pipe(switchMap((c) => of$(c?.displayName || ''))), + }; +}); + +export default withDatabase(enhanced(ChannelAutotranslation)); + diff --git a/app/screens/channel_info/options/edit_channel/index.tsx b/app/screens/channel_settings/channel_info.tsx similarity index 66% rename from app/screens/channel_info/options/edit_channel/index.tsx rename to app/screens/channel_settings/channel_info.tsx index 1a53819aa..a06a209a2 100644 --- a/app/screens/channel_info/options/edit_channel/index.tsx +++ b/app/screens/channel_settings/channel_info.tsx @@ -14,23 +14,23 @@ type Props = { channelId: string; } -const EditChannel = ({channelId}: Props) => { +const ChannelInfoOption = ({channelId}: Props) => { const {formatMessage} = useIntl(); - const title = formatMessage({id: 'screens.channel_edit', defaultMessage: 'Edit Channel'}); + const title = formatMessage({id: 'screens.channel_info', defaultMessage: 'Channel info'}); - const goToEditChannel = usePreventDoubleTap(useCallback(async () => { + const goToChannelInfo = usePreventDoubleTap(useCallback(async () => { goToScreen(Screens.CREATE_OR_EDIT_CHANNEL, title, {channelId}); }, [channelId, title])); return ( ); }; -export default EditChannel; +export default ChannelInfoOption; diff --git a/app/screens/channel_settings/channel_settings.tsx b/app/screens/channel_settings/channel_settings.tsx new file mode 100644 index 000000000..95a8728a9 --- /dev/null +++ b/app/screens/channel_settings/channel_settings.tsx @@ -0,0 +1,141 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {ScrollView, View} from 'react-native'; +import {type Edge, SafeAreaView} from 'react-native-safe-area-context'; + +import ChannelInfoEnableCalls from '@calls/components/channel_info_enable_calls'; +import ConvertToChannelLabel from '@components/channel_actions/convert_to_channel/convert_to_channel_label'; +import {useTheme} from '@context/theme'; +import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import SecurityManager from '@managers/security_manager'; +import {dismissModal} from '@screens/navigation'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import Archive from './archive'; +import ChannelAutotranslation from './channel_autotranslation'; +import ChannelInfoOption from './channel_info'; +import ConvertPrivate from './convert_private'; + +import type {AvailableScreens} from '@typings/screens/navigation'; + +type Props = { + canArchive: boolean; + canConvert: boolean; + canEnableDisableCalls: boolean; + canManageSettings: boolean; + canUnarchive: boolean; + channelId: string; + closeButtonId?: string; + componentId: AvailableScreens; + convertGMOptionAvailable: boolean; + displayName: string; + isCallsEnabledInChannel: boolean; + isChannelAutotranslateEnabled: boolean; + type?: ChannelType; +} + +const edges: Edge[] = ['bottom', 'left', 'right']; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + content: { + paddingHorizontal: 20, + paddingBottom: 16, + }, + flex: { + flex: 1, + }, + separator: { + height: 1, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + marginVertical: 8, + }, +})); + +const ChannelSettings = ({ + canArchive, + canConvert, + canEnableDisableCalls, + canManageSettings, + canUnarchive, + channelId, + closeButtonId, + componentId, + convertGMOptionAvailable, + displayName, + isCallsEnabledInChannel, + isChannelAutotranslateEnabled, + type, +}: Props) => { + const theme = useTheme(); + const styles = getStyleSheet(theme); + + const onPressed = useCallback(() => { + return dismissModal({componentId}); + }, [componentId]); + + if (closeButtonId) { + useNavButtonPressed(closeButtonId, componentId, onPressed, [onPressed]); + } + useAndroidHardwareBackHandler(componentId, onPressed); + + return ( + + + + {canManageSettings && + + } + {canConvert && + + } + {canEnableDisableCalls && + + } + {convertGMOptionAvailable && + + } + {canManageSettings && isChannelAutotranslateEnabled && + + } + {(canArchive || canUnarchive) && + <> + + + + } + + + + ); +}; + +export default ChannelSettings; + diff --git a/app/screens/channel_info/destructive_options/convert_private/convert_private.tsx b/app/screens/channel_settings/convert_private.tsx similarity index 98% rename from app/screens/channel_info/destructive_options/convert_private/convert_private.tsx rename to app/screens/channel_settings/convert_private.tsx index 0214e2926..1a6aba3db 100644 --- a/app/screens/channel_info/destructive_options/convert_private/convert_private.tsx +++ b/app/screens/channel_settings/convert_private.tsx @@ -107,9 +107,10 @@ const ConvertPrivate = ({canConvert, channelId, displayName}: Props) => { label={intl.formatMessage({id: 'channel_info.convert_private', defaultMessage: 'Convert to private channel'})} icon='lock-outline' type='default' - testID='channel_info.options.convert_private.option' + testID='channel_settings.convert_private.option' /> ); }; export default ConvertPrivate; + diff --git a/app/screens/channel_settings/index.ts b/app/screens/channel_settings/index.ts new file mode 100644 index 000000000..484240dea --- /dev/null +++ b/app/screens/channel_settings/index.ts @@ -0,0 +1,196 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; +import {combineLatest, of as of$} from 'rxjs'; +import {distinctUntilChanged, switchMap, combineLatestWith} from 'rxjs/operators'; + +import {observeIsCallsEnabledInChannel} from '@calls/observers'; +import {observeCallsConfig} from '@calls/state'; +import {General, Permissions} from '@constants'; +import {withServerUrl} from '@context/server'; +import {observeChannel} from '@queries/servers/channel'; +import {observePermissionForChannel, observePermissionForTeam, observeCanManageChannelSettings} from '@queries/servers/role'; +import { + observeConfigBooleanValue, + observeConfigValue, + observeCurrentTeamId, +} from '@queries/servers/system'; +import {observeCurrentTeam} from '@queries/servers/team'; +import {observeCurrentUser, observeUserIsChannelAdmin, observeUserIsTeamAdmin} from '@queries/servers/user'; +import {isTypeDMorGM, isDefaultChannel} from '@utils/channel'; +import {isMinimumServerVersion} from '@utils/helpers'; +import {isSystemAdmin} from '@utils/user'; + +import ChannelSettings from './channel_settings'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type Props = WithDatabaseArgs & { + channelId: string; + serverUrl: string; +} + +const enhanced = withObservables(['channelId'], ({channelId, serverUrl, database}: Props) => { + const channel = observeChannel(database, channelId); + const type = channel.pipe(switchMap((c) => of$(c?.type))); + const teamId = channel.pipe(switchMap((c) => (c?.teamId ? of$(c.teamId) : observeCurrentTeamId(database)))); + const currentUser = observeCurrentUser(database); + const team = observeCurrentTeam(database); + const isTeamAdmin = combineLatest([teamId, currentUser]).pipe( + switchMap(([tId, u]) => (u ? observeUserIsTeamAdmin(database, u.id, tId) : of$(false))), + ); + + // Calls observables + const callsPluginEnabled = observeCallsConfig(serverUrl).pipe( + switchMap((config) => of$(config.pluginEnabled)), + distinctUntilChanged(), + ); + const callsDefaultEnabled = observeCallsConfig(serverUrl).pipe( + switchMap((config) => of$(config.DefaultEnabled)), + distinctUntilChanged(), + ); + const allowEnableCalls = observeCallsConfig(serverUrl).pipe( + switchMap((config) => of$(config.AllowEnableCalls)), + distinctUntilChanged(), + ); + const systemAdmin = currentUser.pipe( + switchMap((u) => (u ? of$(u.roles) : of$(''))), + switchMap((roles) => of$(isSystemAdmin(roles || ''))), + distinctUntilChanged(), + ); + const channelAdmin = currentUser.pipe( + switchMap((u) => (u ? observeUserIsChannelAdmin(database, u.id, channelId) : of$(false))), + distinctUntilChanged(), + ); + const serverVersion = observeConfigValue(database, 'Version'); + const callsGAServer = serverVersion.pipe( + switchMap((v) => of$(isMinimumServerVersion(v || '', 7, 6))), + ); + const dmOrGM = type.pipe(switchMap((t) => of$(isTypeDMorGM(t)))); + const canEnableDisableCalls = combineLatest([callsPluginEnabled, callsDefaultEnabled, allowEnableCalls, systemAdmin, channelAdmin, callsGAServer, dmOrGM, isTeamAdmin]).pipe( + switchMap(([pluginEnabled, liveMode, allow, sysAdmin, chAdmin, gaServer, dmGM, tAdmin]) => { + if (!pluginEnabled) { + return of$(false); + } + + if (gaServer) { + if (allow && !liveMode) { + return of$(Boolean(sysAdmin)); + } + if (allow && liveMode) { + return of$(Boolean(chAdmin || tAdmin || sysAdmin || dmGM)); + } + return of$(false); + } + + // now we're pre GA 7.6 + if (allow && liveMode) { + return of$(Boolean(chAdmin || sysAdmin || dmGM)); + } + if (allow && !liveMode) { + return of$(Boolean(sysAdmin || chAdmin || dmGM)); + } + if (!allow) { + return of$(Boolean(sysAdmin)); + } + return of$(false); + }), + ); + const isCallsEnabledInChannel = observeIsCallsEnabledInChannel(database, serverUrl, of$(channelId)); + + const canManageSettings = currentUser.pipe( + switchMap((u) => (u ? observeCanManageChannelSettings(database, channelId, u) : of$(false))), + distinctUntilChanged(), + ); + + // canConvert observable (for Convert to private) + const canConvert = channel.pipe( + combineLatestWith(currentUser), + switchMap(([ch, u]) => { + if (!ch || !u || isDefaultChannel(ch)) { + return of$(false); + } + if (ch.type !== General.OPEN_CHANNEL) { + return of$(false); + } + return observePermissionForChannel(database, ch, u, Permissions.CONVERT_PUBLIC_CHANNEL_TO_PRIVATE, false); + }), + ); + + // Archive observables + const isArchived = channel.pipe(switchMap((c) => of$((c?.deleteAt || 0) > 0))); + const canLeave = channel.pipe( + combineLatestWith(currentUser), + switchMap(([ch, u]) => { + const isDC = isDefaultChannel(ch); + return of$(!isDC || (isDC && u?.isGuest)); + }), + ); + + const canArchive = channel.pipe( + combineLatestWith(currentUser, canLeave, isArchived, type), + switchMap(([ch, u, leave, archived, chType]) => { + if ( + chType === General.DM_CHANNEL || chType === General.GM_CHANNEL || + !ch || !u || !leave || archived + ) { + return of$(false); + } + + if (chType === General.OPEN_CHANNEL) { + return observePermissionForChannel(database, ch, u, Permissions.DELETE_PUBLIC_CHANNEL, true); + } + + return observePermissionForChannel(database, ch, u, Permissions.DELETE_PRIVATE_CHANNEL, true); + }), + ); + + const canUnarchive = team.pipe( + combineLatestWith(currentUser, isArchived, type), + switchMap(([t, u, archived, chType]) => { + if ( + chType === General.DM_CHANNEL || chType === General.GM_CHANNEL || + !t || !u || !archived + ) { + return of$(false); + } + + return observePermissionForTeam(database, t, u, Permissions.MANAGE_TEAM, false); + }), + ); + + // Convert GM to channel observable + const isGuestUser = currentUser.pipe( + switchMap((u) => (u ? of$(u.isGuest) : of$(false))), + distinctUntilChanged(), + ); + + const isConvertGMFeatureAvailable = serverVersion.pipe( + switchMap((version) => of$(isMinimumServerVersion(version || '', 9, 1))), + ); + + const convertGMOptionAvailable = combineLatest([isConvertGMFeatureAvailable, type, isGuestUser]).pipe( + switchMap(([available, chType, guest]) => of$(available && chType === General.GM_CHANNEL && !guest)), + ); + + // Channel autotranslation observable + const isChannelAutotranslateEnabled = observeConfigBooleanValue(database, 'EnableAutoTranslation'); + + return { + canArchive, + canConvert, + canEnableDisableCalls, + canManageSettings, + canUnarchive, + convertGMOptionAvailable, + displayName: channel.pipe(switchMap((c) => of$(c?.displayName || ''))), + isCallsEnabledInChannel, + isChannelAutotranslateEnabled, + isGuestUser, + type, + }; +}); + +export default withDatabase(withServerUrl(enhanced(ChannelSettings))); + diff --git a/app/screens/global_threads/threads_list/thread/index.ts b/app/screens/global_threads/threads_list/thread/index.ts index c27b4e83d..8a1ad5ee9 100644 --- a/app/screens/global_threads/threads_list/thread/index.ts +++ b/app/screens/global_threads/threads_list/thread/index.ts @@ -5,7 +5,7 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; -import {observeChannel} from '@queries/servers/channel'; +import {observeChannel, observeIsChannelAutotranslated} from '@queries/servers/channel'; import {observePost} from '@queries/servers/post'; import {observeUser} from '@queries/servers/user'; @@ -25,6 +25,9 @@ const enhanced = withObservables([], ({database, thread}: WithDatabaseArgs & {th author: post.pipe( switchMap((u) => (u?.userId ? observeUser(database, u.userId) : of$(undefined))), ), + isChannelAutotranslated: post.pipe( + switchMap((p) => (p?.channelId ? observeIsChannelAutotranslated(database, p.channelId) : of$(undefined))), + ), }; }); diff --git a/app/screens/global_threads/threads_list/thread/thread.tsx b/app/screens/global_threads/threads_list/thread/thread.tsx index 0ed8d9b0e..67d9770c0 100644 --- a/app/screens/global_threads/threads_list/thread/thread.tsx +++ b/app/screens/global_threads/threads_list/thread/thread.tsx @@ -7,6 +7,7 @@ import {Text, TouchableHighlight, View} from 'react-native'; import {switchToChannelById} from '@actions/remote/channel'; import {fetchAndSwitchToThread} from '@actions/remote/thread'; +import CompassIcon from '@components/compass_icon'; import FormattedText from '@components/formatted_text'; import FriendlyDate from '@components/friendly_date'; import RemoveMarkdown from '@components/remove_markdown'; @@ -17,6 +18,7 @@ import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; import {usePreventDoubleTap} from '@hooks/utils'; import {bottomSheetModalOptions, showModal, showModalOverCurrentContext} from '@screens/navigation'; +import {getPostTranslatedMessage, getPostTranslation} from '@utils/post'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; import {displayUsername} from '@utils/user'; @@ -37,6 +39,7 @@ type Props = { teammateNameDisplay: string; testID: string; thread: ThreadModel; + isChannelAutotranslated: boolean; }; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { @@ -69,6 +72,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { flexDirection: 'row', marginRight: 12, overflow: 'hidden', + gap: 6, }, threadDeleted: { color: changeOpacity(theme.centerChannelColor, 0.72), @@ -77,7 +81,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { threadStarter: { color: theme.centerChannelColor, ...typography('Body', 200, 'SemiBold'), - paddingRight: 6, }, channelNameContainer: { backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), @@ -123,7 +126,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }; }); -const Thread = ({author, channel, location, post, teammateNameDisplay, testID, thread}: Props) => { +const Thread = ({author, channel, location, post, teammateNameDisplay, testID, thread, isChannelAutotranslated}: Props) => { const intl = useIntl(); const isTablet = useIsTablet(); const theme = useTheme(); @@ -165,6 +168,12 @@ const Thread = ({author, channel, location, post, teammateNameDisplay, testID, t return null; } + const translation = getPostTranslation(post, intl.locale); + let message = post.message; + if (isChannelAutotranslated && post.type === '' && translation?.state === 'ready') { + message = getPostTranslatedMessage(message, translation); + } + const threadStarterName = displayUsername(author, intl.locale, teammateNameDisplay); const threadItemTestId = `${testID}.thread_item.${thread.id}`; @@ -212,7 +221,7 @@ const Thread = ({author, channel, location, post, teammateNameDisplay, testID, t {threadStarterName} ); - if (post?.message) { + if (message) { postBody = ( ); @@ -244,6 +253,13 @@ const Thread = ({author, channel, location, post, teammateNameDisplay, testID, t {name} + {isChannelAutotranslated && post.type === '' && translation?.state === 'ready' && ( + + )} {threadStarterName !== channel?.displayName && ( { case Screens.CHANNEL_NOTIFICATION_PREFERENCES: screen = withServerDatabase(require('@screens/channel_notification_preferences').default); break; + case Screens.CHANNEL_SETTINGS: + screen = withServerDatabase(require('@screens/channel_settings').default); + break; case Screens.CHANNEL_FILES: screen = withServerDatabase(require('@screens/channel_files').default); break; @@ -296,6 +299,9 @@ Navigation.setLazyComponentRegistrator((screenName) => { case Screens.USER_PROFILE: screen = withServerDatabase(require('@screens/user_profile').default); break; + case Screens.SHOW_TRANSLATION: + screen = withServerDatabase(require('@screens/show_translation').default); + break; case Screens.CALL: screen = withServerDatabase(require('@calls/screens/call_screen').default); break; diff --git a/app/screens/permalink/permalink.tsx b/app/screens/permalink/permalink.tsx index 3c97d942f..fb98c562e 100644 --- a/app/screens/permalink/permalink.tsx +++ b/app/screens/permalink/permalink.tsx @@ -319,7 +319,7 @@ function Permalink({ handleJoin={handleJoin} /> ); - } else { + } else if (channel) { const postListContent = ( <> @@ -330,7 +330,7 @@ function Permalink({ location={Screens.PERMALINK} lastViewedAt={0} shouldShowJoinLeaveMessages={false} - channelId={channel!.id} + channelId={channel.id} rootId={rootId} testID='permalink.post_list' highlightPinnedOrSaved={false} diff --git a/app/screens/pinned_messages/index.ts b/app/screens/pinned_messages/index.ts index 96f2ca6ef..91c8929b3 100644 --- a/app/screens/pinned_messages/index.ts +++ b/app/screens/pinned_messages/index.ts @@ -5,6 +5,7 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; +import {observeIsChannelAutotranslated} from '@queries/servers/channel'; import {queryAllCustomEmojis} from '@queries/servers/custom_emoji'; import {observePinnedPostsInChannel} from '@queries/servers/post'; import {observeConfigBooleanValue} from '@queries/servers/system'; @@ -33,6 +34,7 @@ const enhance = withObservables(['channelId'], ({channelId, database}: Props) => ), isCRTEnabled: observeIsCRTEnabled(database), posts, + isChannelAutotranslated: observeIsChannelAutotranslated(database, channelId), }; }); diff --git a/app/screens/pinned_messages/pinned_messages.tsx b/app/screens/pinned_messages/pinned_messages.tsx index b59775860..4662fc8fd 100644 --- a/app/screens/pinned_messages/pinned_messages.tsx +++ b/app/screens/pinned_messages/pinned_messages.tsx @@ -32,6 +32,7 @@ type Props = { customEmojiNames: string[]; isCRTEnabled: boolean; posts: PostModel[]; + isChannelAutotranslated: boolean; } const edges: Edge[] = ['bottom', 'left', 'right']; @@ -58,13 +59,14 @@ function SavedMessages({ customEmojiNames, isCRTEnabled, posts, + isChannelAutotranslated, }: Props) { const [loading, setLoading] = useState(!posts.length); const [refreshing, setRefreshing] = useState(false); const theme = useTheme(); const serverUrl = useServerUrl(); - const data = useMemo(() => selectOrderedPosts(posts, 0, false, '', '', false, currentTimezone, false).reverse(), [posts]); + const data = useMemo(() => selectOrderedPosts(posts, 0, false, '', '', false, currentTimezone, false).reverse(), [currentTimezone, posts]); const close = useCallback(() => { if (componentId) { @@ -76,6 +78,9 @@ function SavedMessages({ fetchPinnedPosts(serverUrl, channelId).finally(() => { setLoading(false); }); + + // Only fetch pinned posts on initial load + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useAndroidHardwareBackHandler(componentId, close); @@ -141,12 +146,13 @@ function SavedMessages({ skipSavedHeader={true} skipPinnedHeader={true} testID='pinned_messages.post_list.post' + isChannelAutotranslated={isChannelAutotranslated} /> ); default: return null; } - }, [appsEnabled, currentTimezone, customEmojiNames, theme]); + }, [appsEnabled, currentTimezone, customEmojiNames, isCRTEnabled, isChannelAutotranslated]); return ( (enabled ? observeThreadById(database, post.id) : of$(undefined))), ); + const canViewTranslation = observeIsChannelAutotranslated(database, post.channelId).pipe( + combineLatestWith(currentUser), + switchMap(([isAutotranslated, user]) => { + const translation = getPostTranslation(post, user?.locale || DEFAULT_LOCALE); + return of$(isAutotranslated && post.type === '' && translation?.state === 'ready'); + }), + distinctUntilChanged(), + ); + const showBoRReadReceipts = combineLatest([currentUser]).pipe( switchMap(([user]) => { return of$(isOwnBoRPost(post, user?.id)); @@ -184,6 +194,7 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, sour combinedPost: of$(combinedPost), isSaved, canEdit, + canViewTranslation, post, thread, bindings, diff --git a/app/screens/post_options/post_options.tsx b/app/screens/post_options/post_options.tsx index f60e3acbb..712b908ee 100644 --- a/app/screens/post_options/post_options.tsx +++ b/app/screens/post_options/post_options.tsx @@ -6,7 +6,7 @@ import {useManagedConfig} from '@mattermost/react-native-emm'; import React, {useMemo} from 'react'; import {ScrollView} from 'react-native'; -import {CopyPermalinkOption, FollowThreadOption, ReplyOption, SaveOption} from '@components/common_post_options'; +import {CopyPermalinkOption, FollowThreadOption, ReplyOption, SaveOption, ShowTranslationOption} from '@components/common_post_options'; import CopyTextOption from '@components/copy_text_option'; import {ITEM_HEIGHT} from '@components/option_item'; import {Screens} from '@constants'; @@ -43,6 +43,7 @@ type PostOptionsProps = { canMarkAsUnread: boolean; canPin: boolean; canReply: boolean; + canViewTranslation: boolean; combinedPost?: Post | PostModel; isSaved: boolean; sourceScreen: AvailableScreens; @@ -58,7 +59,7 @@ type PostOptionsProps = { }; const PostOptions = ({ canAddReaction, canDelete, canEdit, - canMarkAsUnread, canPin, canReply, + canMarkAsUnread, canPin, canReply, canViewTranslation, combinedPost, componentId, isSaved, sourceScreen, post, thread, bindings, serverUrl, isBoRPost, showBoRReadReceipts, borReceiptData, currentUser, @@ -91,7 +92,7 @@ const PostOptions = ({ const items: Array = [1]; const optionsCount = [ canCopyPermalink, canCopyText, canDelete, canEdit, - canMarkAsUnread, canPin, canReply, canSavePost, shouldRenderFollow, + canMarkAsUnread, canPin, canReply, canSavePost, shouldRenderFollow, canViewTranslation, ].reduce((acc, v) => { return v ? acc + 1 : acc; }, 0) + (shouldShowBindings ? 0.5 : 0); @@ -110,7 +111,7 @@ const PostOptions = ({ }, [ canAddReaction, canCopyPermalink, canCopyText, canDelete, canEdit, shouldRenderFollow, shouldShowBindings, - canMarkAsUnread, canPin, canReply, canSavePost, + canMarkAsUnread, canPin, canReply, canSavePost, canViewTranslation, ]); const renderContent = () => { @@ -158,6 +159,12 @@ const PostOptions = ({ sourceScreen={sourceScreen} /> } + {canViewTranslation && + + } {canSavePost && { + const post = observePost(database, postId); + const appsEnabled = observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'); + const customEmojiNames = queryAllCustomEmojis(database).observe().pipe( + switchMap((customEmojis) => of$(mapCustomEmojiNames(customEmojis))), + ); + const isCRTEnabled = observeIsCRTEnabled(database); + return { + post, + appsEnabled, + customEmojiNames, + isCRTEnabled, + }; +}); + +export default withDatabase(enhance(ShowTranslation)); + diff --git a/app/screens/show_translation/show_translation.test.tsx b/app/screens/show_translation/show_translation.test.tsx new file mode 100644 index 000000000..696a6ee8f --- /dev/null +++ b/app/screens/show_translation/show_translation.test.tsx @@ -0,0 +1,155 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {type ComponentProps} from 'react'; + +import Post from '@components/post_list/post'; +import {Screens} from '@constants'; +import {popTopScreen} from '@screens/navigation'; +import {act, renderWithIntlAndTheme} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; +import {getPostTranslation} from '@utils/post'; + +import ShowTranslation from './show_translation'; + +import type {AvailableScreens} from '@typings/screens/navigation'; + +jest.mock('@screens/navigation', () => ({ + popTopScreen: jest.fn(), +})); +jest.mock('@hooks/android_back_handler', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mock('@utils/post', () => ({ + getPostTranslation: jest.fn(), +})); +jest.mock('@components/post_list/post', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mocked(Post).mockImplementation((props) => + React.createElement('Post', {testID: 'show_translation.post', ...props}), +); + +describe('ShowTranslation', () => { + function getBaseProps(): ComponentProps { + return { + componentId: Screens.SHOW_TRANSLATION as AvailableScreens, + post: undefined, + appsEnabled: false, + customEmojiNames: [], + isCRTEnabled: false, + }; + } + + const mockTranslation = { + object: {message: 'Translated text'}, + state: 'ready' as const, + source_lang: 'en', + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(getPostTranslation).mockReturnValue(mockTranslation); + }); + + it('returns null when post is not provided', () => { + const props = getBaseProps(); + const {queryByTestId} = renderWithIntlAndTheme(); + + expect(queryByTestId('show_translation.screen')).toBeNull(); + }); + + it('renders two Post instances (original and translated)', () => { + const post = TestHelper.fakePostModel({id: 'post1', channelId: 'channel1'}); + const props = { + ...getBaseProps(), + post, + }; + const {getAllByTestId} = renderWithIntlAndTheme(); + + const postComponents = getAllByTestId('show_translation.post'); + expect(postComponents).toHaveLength(2); + }); + + it('passes correct props to Post for original and translated blocks', () => { + const post = TestHelper.fakePostModel({id: 'post1', channelId: 'channel1'}); + const props = { + ...getBaseProps(), + post, + appsEnabled: true, + customEmojiNames: ['custom_emoji'], + isCRTEnabled: true, + }; + const {getAllByTestId} = renderWithIntlAndTheme(); + + const postComponents = getAllByTestId('show_translation.post'); + expect(postComponents).toHaveLength(2); + + const originalPost = postComponents[0]; + expect(originalPost).toHaveProp('post', post); + expect(originalPost).toHaveProp('location', Screens.SHOW_TRANSLATION); + expect(originalPost).toHaveProp('isChannelAutotranslated', false); + expect(originalPost).toHaveProp('appsEnabled', true); + expect(originalPost).toHaveProp('customEmojiNames', ['custom_emoji']); + expect(originalPost).toHaveProp('isCRTEnabled', true); + expect(originalPost).toHaveProp('shouldRenderReplyButton', false); + expect(originalPost).toHaveProp('showAddReaction', false); + + const translatedPost = postComponents[1]; + expect(translatedPost).toHaveProp('post', post); + expect(translatedPost).toHaveProp('location', Screens.SHOW_TRANSLATION); + expect(translatedPost).toHaveProp('isChannelAutotranslated', true); + }); + + it('sets up Android back handler with componentId and close callback', () => { + const useAndroidHardwareBackHandler = require('@hooks/android_back_handler').default; + const componentId = Screens.SHOW_TRANSLATION as AvailableScreens; + const post = TestHelper.fakePostModel({id: 'post1'}); + const props = { + ...getBaseProps(), + post, + componentId, + }; + renderWithIntlAndTheme(); + + expect(useAndroidHardwareBackHandler).toHaveBeenCalledWith( + componentId, + expect.any(Function), + ); + + const closeHandler = jest.mocked(useAndroidHardwareBackHandler).mock.calls[0][1]; + act(() => { + closeHandler(); + }); + expect(popTopScreen).toHaveBeenCalledWith(componentId); + }); + + it('displays ORIGINAL and AUTO-TRANSLATED badges when translation is available', () => { + const post = TestHelper.fakePostModel({id: 'post1'}); + const props = { + ...getBaseProps(), + post, + }; + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('ORIGINAL')).toBeTruthy(); + expect(getByText('AUTO-TRANSLATED')).toBeTruthy(); + }); + + it('displays Unknown for language when translation has no source_lang', () => { + jest.mocked(getPostTranslation).mockReturnValue({ + ...mockTranslation, + source_lang: undefined, + }); + const post = TestHelper.fakePostModel({id: 'post1'}); + const props = { + ...getBaseProps(), + post, + }; + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('Unknown')).toBeTruthy(); + }); +}); diff --git a/app/screens/show_translation/show_translation.tsx b/app/screens/show_translation/show_translation.tsx new file mode 100644 index 000000000..5fd9e9a8c --- /dev/null +++ b/app/screens/show_translation/show_translation.tsx @@ -0,0 +1,148 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; +import {ScrollView, Text, View} from 'react-native'; +import {type Edge, SafeAreaView} from 'react-native-safe-area-context'; + +import Post from '@components/post_list/post'; +import Tag from '@components/tag'; +import {Screens} from '@constants'; +import {useTheme} from '@context/theme'; +import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import {popTopScreen} from '@screens/navigation'; +import {getPostTranslation} from '@utils/post'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import type PostModel from '@typings/database/models/servers/post'; +import type {AvailableScreens} from '@typings/screens/navigation'; + +type Props = { + componentId: AvailableScreens; + post?: PostModel; + appsEnabled: boolean; + customEmojiNames: string[]; + isCRTEnabled: boolean; +} + +const edges: Edge[] = ['bottom', 'left', 'right']; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + flex: 1, + backgroundColor: theme.centerChannelBg, + padding: 8, + }, + content: { + gap: 8, + }, + messageBlock: { + backgroundColor: theme.centerChannelBg, + }, + messageBlockOriginal: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.04), + borderRadius: 4, + paddingBottom: 8, + }, + badgeContainer: { + flexDirection: 'row', + alignItems: 'center', + margin: 12, + gap: 8, + }, + languageText: { + color: theme.centerChannelColor, + textTransform: 'uppercase', + ...typography('Heading', 50, 'SemiBold'), + }, + }; +}); + +function ShowTranslation({ + componentId, + post, + appsEnabled, + customEmojiNames, + isCRTEnabled, +}: Props) { + const theme = useTheme(); + const intl = useIntl(); + const style = getStyleSheet(theme); + + const close = useCallback(() => { + if (componentId) { + popTopScreen(componentId); + } + }, [componentId]); + + useAndroidHardwareBackHandler(componentId, close); + + if (!post) { + return null; + } + + const translation = getPostTranslation(post, intl.locale); + const originalLanguage = translation?.source_lang || 'unknown'; + + const renderMessageBlock = ( + language: string, + original: boolean, + ) => { + const badgeLabel = original ? intl.formatMessage({id: 'mobile.translation.original_badge', defaultMessage: 'ORIGINAL'}) : intl.formatMessage({id: 'mobile.translation.auto_translated_badge', defaultMessage: 'AUTO-TRANSLATED'}); + return ( + + + {language === 'unknown' ? intl.formatMessage({id: 'mobile.translation.unknown_language', defaultMessage: 'Unknown'}) : intl.formatDisplayName(language, {type: 'language'})} + + + + + ); + }; + + return ( + + + {renderMessageBlock( + originalLanguage, + true, + )} + {renderMessageBlock( + intl.locale, + false, + )} + + + ); +} + +export default ShowTranslation; + diff --git a/app/screens/snack_bar/index.tsx b/app/screens/snack_bar/index.tsx index ccf530a02..214c580dc 100644 --- a/app/screens/snack_bar/index.tsx +++ b/app/screens/snack_bar/index.tsx @@ -54,7 +54,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { text: { color: theme.centerChannelBg, }, - undo: { + action: { color: theme.centerChannelBg, ...typography('Body', 100, 'SemiBold'), }, @@ -101,6 +101,7 @@ const SnackBar = ({ sourceScreen, customMessage, type, + actionText, }: SnackBarProps) => { const [showSnackBar, setShowSnackBar] = useState(); const intl = useIntl(); @@ -120,7 +121,7 @@ const SnackBar = ({ config = { message: defaultMessage, iconName: DEFAULT_ICON, - canUndo: false, + hasAction: false, type, }; } @@ -245,6 +246,9 @@ const SnackBar = ({ stopTimers(); mounted.current = false; }; + + // Only run on initial load + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // This effect dismisses the Navigation Overlay after we have hidden the snack bar @@ -301,10 +305,10 @@ const SnackBar = ({ textStyle={styles.text} testID='toast' > - {config.canUndo && onAction && ( + {config.hasAction && onAction && ( - - {intl.formatMessage({ + + {actionText || intl.formatMessage({ id: 'snack.bar.undo', defaultMessage: 'Undo', })} diff --git a/app/store/ephemeral_store.ts b/app/store/ephemeral_store.ts index a8f4f0efb..f3bb23996 100644 --- a/app/store/ephemeral_store.ts +++ b/app/store/ephemeral_store.ts @@ -50,6 +50,22 @@ class EphemeralStoreSingleton { // It is cleared any time the connection with the server is lost. private channelPlaybooksSynced: {[serverUrl: string]: Set} = {}; + // Track how many translations are being executed at the same time on the channel. + // We limit this to avoid overwhelming the device. + private runningTranslations = new Set(); + + addRunningTranslation = (postId: string) => { + this.runningTranslations.add(postId); + }; + + removeRunningTranslation = (postId: string) => { + this.runningTranslations.delete(postId); + }; + + totalRunningTranslations = () => { + return this.runningTranslations.size; + }; + setProcessingNotification = (v: string) => { this.processingNotification = v; }; diff --git a/app/utils/post/index.test.ts b/app/utils/post/index.test.ts index e8aca82f6..bc45e460c 100644 --- a/app/utils/post/index.test.ts +++ b/app/utils/post/index.test.ts @@ -59,7 +59,7 @@ describe('post utils', () => { props: {}, }); - const result = areConsecutivePosts(post, previousPost); + const result = areConsecutivePosts(post, previousPost, 'en'); expect(result).toBe(true); }); @@ -75,7 +75,7 @@ describe('post utils', () => { props: {}, }); - const result = areConsecutivePosts(post, previousPost); + const result = areConsecutivePosts(post, previousPost, 'en'); expect(result).toBe(false); }); }); diff --git a/app/utils/post/index.ts b/app/utils/post/index.ts index d66497e53..30ca4e6de 100644 --- a/app/utils/post/index.ts +++ b/app/utils/post/index.ts @@ -18,7 +18,7 @@ import type PostModel from '@typings/database/models/servers/post'; import type UserModel from '@typings/database/models/servers/user'; import type {IntlShape} from 'react-intl'; -export function areConsecutivePosts(post: PostModel, previousPost: PostModel) { +export function areConsecutivePosts(post: PostModel, previousPost: PostModel, userLocale: string) { let consecutive = false; if (post && previousPost) { @@ -33,6 +33,14 @@ export function areConsecutivePosts(post: PostModel, previousPost: PostModel) { !prevPostFromWebhook && isNotSystemMessage; } + if (consecutive) { + const postTranslation = getPostTranslation(post, userLocale); + const previousPostTranslation = getPostTranslation(previousPost, userLocale); + if (postTranslation?.state !== previousPostTranslation?.state) { + consecutive = false; + } + } + return consecutive; } @@ -273,3 +281,12 @@ export function scheduledPostFromPost(post: Post, schedulingInfo: SchedulingInfo file_ids: fileIDs, }; } + +export function getPostTranslation(post: Post | PostModel, locale: string): PostTranslation | undefined { + const normalizedLocale = locale.split('-')[0]; + return post.metadata?.translations?.[normalizedLocale]; +} + +export function getPostTranslatedMessage(originalMessage: string, translation: PostTranslation): string { + return translation.object?.message ?? originalMessage; +} diff --git a/app/utils/snack_bar/index.ts b/app/utils/snack_bar/index.ts index a676947f0..d7e63a1ea 100644 --- a/app/utils/snack_bar/index.ts +++ b/app/utils/snack_bar/index.ts @@ -14,6 +14,7 @@ export type ShowSnackBarArgs = { messageValues?: Record; customMessage?: string; type?: SnackBarConfig['type']; + actionText?: string; }; export const showSnackBar = (passProps: ShowSnackBarArgs) => { @@ -71,6 +72,15 @@ export const showPlaybookErrorSnackbar = () => { }); }; +export const showEnableTranslationSnackbar = (onAction: () => void, location: AvailableScreens) => { + return showSnackBar({ + onAction, + barType: SNACK_BAR_TYPE.ENABLE_TRANSLATION, + sourceScreen: location, + actionText: 'Enable', + }); +}; + export const showBoRPostErrorSnackbar = (message?: string) => { return showSnackBar({ barType: SNACK_BAR_TYPE.BOR_POST_EXPIRED, diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index acf52bd43..f09162110 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -160,6 +160,7 @@ "channel_info.channel_auto_follow_threads": "Follow all threads in this channel", "channel_info.channel_auto_follow_threads_failed": "An error occurred trying to auto follow all threads in channel {displayName}", "channel_info.channel_files": "Files", + "channel_info.channel_settings": "Channel Settings", "channel_info.close": "Close", "channel_info.close_dm": "Close direct message", "channel_info.close_dm_channel": "Are you sure you want to close this direct message? This will remove it from your home screen, but you can always open it again.", @@ -201,6 +202,11 @@ "channel_info.mention": "Mention", "channel_info.mobile_notifications": "Mobile Notifications", "channel_info.muted": "Mute", + "channel_info.my_autotranslation": "Auto-translation", + "channel_info.my_autotranslation_disabled": "Off", + "channel_info.my_autotranslation_enabled": "On ({language})", + "channel_info.my_autotranslation_failed": "An error occurred trying to enable automatic translation for yourself in channel {displayName}", + "channel_info.my_autotranslation_language_not_supported": "Your language is not supported", "channel_info.nickname": "Nickname", "channel_info.notification.all": "All", "channel_info.notification.default": "Default", @@ -214,6 +220,10 @@ "channel_info.send_a_mesasge": "Send a message", "channel_info.send_mesasge": "Send message", "channel_info.set_header": "Set Header", + "channel_info.turn_off_auto_translation.button.cancel": "cancel", + "channel_info.turn_off_auto_translation.button.yes": "Yes, turn off", + "channel_info.turn_off_auto_translation.description": "Messages in this channel will revert to their original language. This will only affect how you see this channel. Other members won’t be affected.", + "channel_info.turn_off_auto_translation.title": "Turn off auto-translation", "channel_info.unarchive": "Unarchive Channel", "channel_info.unarchive_description": "Are you sure you want to unarchive the {term} {name}?", "channel_info.unarchive_failed": "An error occurred trying to unarchive the channel {displayName}", @@ -249,6 +259,9 @@ "channel_notification_preferences.reset_default": "Reset to default", "channel_notification_preferences.thread_replies": "Thread replies", "channel_notification_preferences.unmute_content": "Unmute channel", + "channel_settings.channel_autotranslation": "Auto-translation", + "channel_settings.channel_autotranslation_description": "When enabled, channel members can turn on auto-translation to view messages in their preferred language.", + "channel_settings.channel_autotranslation_failed": "An error occurred trying to enable automatic translation for channel {displayName}", "channel.abac_policy_enforced.description": "Only people who match the specified access rules can be selected and added to this channel.", "channel.abac_policy_enforced.title": "Channel access is restricted by user attributes", "channel.banner.bottom_sheet.title": "Channel Banner", @@ -839,6 +852,7 @@ "mobile.post_info.pin": "Pin to Channel", "mobile.post_info.reply": "Reply", "mobile.post_info.save": "Save", + "mobile.post_info.show_translation": "Show Translation", "mobile.post_info.unpin": "Unpin from Channel", "mobile.post_info.unsave": "Unsave", "mobile.post_pre_header.pinned": "Pinned", @@ -929,6 +943,9 @@ "mobile.system_message.update_channel_purpose_message.updated_from": "{username} updated the channel purpose from: {oldPurpose} to: {newPurpose}", "mobile.system_message.update_channel_purpose_message.updated_to": "{username} updated the channel purpose to: {newPurpose}", "mobile.tos_link": "Terms of Service", + "mobile.translation.auto_translated_badge": "AUTO-TRANSLATED", + "mobile.translation.original_badge": "ORIGINAL", + "mobile.translation.unknown_language": "Unknown", "mobile.user_list.deactivated": "Deactivated", "mobile.write_storage_permission_denied_description": "Save files to your device. Open Settings to grant {applicationName} write access to files on this device.", "modal.manual_status.auto_responder.message_": "Would you like to switch your status to \"{status}\" and disable Automatic Replies?", @@ -1196,6 +1213,7 @@ "post_body.check_for_out_of_channel_mentions.message.one": "was mentioned but is not in the channel. Would you like to ", "post_body.commentedOn": "Commented on {name}{apostrophe} message: ", "post_body.deleted": "(message deleted)", + "post_header.translation_processing": "Translating...", "post_header.visible_message": "(Only visible to you)", "post_info.auto_responder": "Automatic Reply", "post_info.bot": "Bot", @@ -1320,7 +1338,6 @@ "screen.search.title": "Search", "screens.channel_bookmark_add": "Add a bookmark", "screens.channel_bookmark_edit": "Edit bookmark", - "screens.channel_edit": "Edit Channel", "screens.channel_edit_header": "Edit Channel Header", "screens.channel_info": "Channel info", "screens.channel_info.dm": "Direct message info", @@ -1448,6 +1465,7 @@ "snack.bar.channel.members.added": "{numMembers, number} {numMembers, plural, one {member} other {members}} added", "snack.bar.code.copied": "Code copied to clipboard", "snack.bar.default": "Error", + "snack.bar.enable.translation": "Enable auto-translation?", "snack.bar.favorited.channel": "This channel was favorited", "snack.bar.following.thread": "Thread followed", "snack.bar.info.copied": "Info copied to clipboard", diff --git a/detox/e2e/support/ui/screen/channel_info.ts b/detox/e2e/support/ui/screen/channel_info.ts index 5f57b14c3..4ca2f2f2e 100644 --- a/detox/e2e/support/ui/screen/channel_info.ts +++ b/detox/e2e/support/ui/screen/channel_info.ts @@ -7,7 +7,7 @@ import { } from '@support/ui/component'; import {ChannelScreen} from '@support/ui/screen'; import {isAndroid, timeouts, wait} from '@support/utils'; -import {expect} from 'detox'; +import {expect, waitFor} from 'detox'; class ChannelInfoScreen { testID = { @@ -35,11 +35,8 @@ class ChannelInfoScreen { pinnedMessagesOption: 'channel_info.options.pinned_messages.option', membersOption: 'channel_info.options.members.option', copyChannelLinkOption: 'channel_info.options.copy_channel_link.option', - editChannelOption: 'channel_info.options.edit_channel.option', - convertPrivateOption: 'channel_info.options.convert_private.option', + channelSettingsOption: 'channel_info.options.channel_settings.option', leaveChannelOption: 'channel_info.options.leave_channel.option', - archiveChannelOption: 'channel_info.options.archive_channel.option', - unarchiveChannelOption: 'channel_info.options.unarchive_channel.option', }; channelInfoScreen = element(by.id(this.testID.channelInfoScreen)); @@ -65,11 +62,8 @@ class ChannelInfoScreen { pinnedMessagesOption = element(by.id(this.testID.pinnedMessagesOption)); membersOption = element(by.id(this.testID.membersOption)); copyChannelLinkOption = element(by.id(this.testID.copyChannelLinkOption)); - editChannelOption = element(by.id(this.testID.editChannelOption)); - convertPrivateOption = element(by.id(this.testID.convertPrivateOption)); + channelSettingsOption = element(by.id(this.testID.channelSettingsOption)); leaveChannelOption = element(by.id(this.testID.leaveChannelOption)); - archiveChannelOption = element(by.id(this.testID.archiveChannelOption)); - unarchiveChannelOption = element(by.id(this.testID.unarchiveChannelOption)); getDirectMessageTitle = (userId: string) => { const directMessageTitleTestId = `${this.testID.directMessageTitlePrefix}${userId}`; @@ -109,62 +103,9 @@ class ChannelInfoScreen { await expect(this.channelInfoScreen).not.toBeVisible(); }; - archiveChannel = async (alertArchiveChannelTitle: Detox.NativeElement, {confirm = true} = {}) => { - await waitFor(this.archiveChannelOption).toBeVisible().whileElement(by.id(this.testID.scrollView)).scroll(50, 'down'); - await this.archiveChannelOption.tap({x: 1, y: 1}); - const { - noButton, - yesButton, - } = Alert; - await wait(timeouts.TWO_SEC); - await expect(alertArchiveChannelTitle).toBeVisible(); - await expect(noButton).toBeVisible(); - await expect(yesButton).toBeVisible(); - if (confirm) { - await yesButton.tap(); - await wait(timeouts.ONE_SEC); - await expect(this.channelInfoScreen).not.toExist(); - } else { - await noButton.tap(); - await wait(timeouts.ONE_SEC); - await expect(this.channelInfoScreen).toExist(); - } - }; - - archivePrivateChannel = async ({confirm = true} = {}) => { - await this.archiveChannel(Alert.archivePrivateChannelTitle, {confirm}); - }; - - archivePublicChannel = async ({confirm = true} = {}) => { - await this.archiveChannel(Alert.archivePublicChannelTitle, {confirm}); - }; - - convertToPrivateChannel = async (channelDisplayName: string, {confirm = true} = {}) => { - await this.scrollView.tap({x: 1, y: 1}); - await this.scrollView.scroll(100, 'down'); - await waitFor(this.convertPrivateOption).toBeVisible().whileElement(by.id(this.testID.scrollView)).scroll(50, 'down'); - await this.convertPrivateOption.tap({x: 1, y: 1}); - const { - channelNowPrivateTitle, - convertToPrivateChannelTitle, - noButton2, - okButton, - yesButton2, - } = Alert; - await expect(convertToPrivateChannelTitle(channelDisplayName)).toBeVisible(); - await expect(noButton2).toBeVisible(); - await expect(yesButton2).toBeVisible(); - if (confirm) { - await yesButton2.tap(); - await expect(channelNowPrivateTitle(channelDisplayName)).toBeVisible(); - await okButton.tap(); - await wait(timeouts.ONE_SEC); - await expect(this.channelInfoScreen).toExist(); - } else { - await noButton2.tap(); - await wait(timeouts.ONE_SEC); - await expect(this.channelInfoScreen).toExist(); - } + openChannelSettings = async () => { + await waitFor(this.channelSettingsOption).toBeVisible().withTimeout(timeouts.TEN_SEC); + await this.channelSettingsOption.tap({x: 1, y: 1}); }; leaveChannel = async ({confirm = true} = {}) => { @@ -204,36 +145,6 @@ class ChannelInfoScreen { await expect(this.ignoreMentionsOptionToggledOff).toBeVisible(); }; - unarchiveChannel = async (alertUnarchiveChannelTitle: Detox.NativeElement, {confirm = true} = {}) => { - await waitFor(this.unarchiveChannelOption).toBeVisible().whileElement(by.id(this.testID.scrollView)).scroll(50, 'down'); - await wait(timeouts.TWO_SEC); - await this.unarchiveChannelOption.tap({x: 1, y: 1}); - const { - noButton, - yesButton, - } = Alert; - await expect(alertUnarchiveChannelTitle).toBeVisible(); - await expect(noButton).toBeVisible(); - await expect(yesButton).toBeVisible(); - if (confirm) { - await yesButton.tap(); - await wait(timeouts.ONE_SEC); - await expect(this.channelInfoScreen).not.toExist(); - } else { - await noButton.tap(); - await wait(timeouts.ONE_SEC); - await expect(this.channelInfoScreen).toExist(); - } - }; - - unarchivePrivateChannel = async ({confirm = true} = {}) => { - await this.unarchiveChannel(Alert.unarchivePrivateChannelTitle, {confirm}); - }; - - unarchivePublicChannel = async ({confirm = true} = {}) => { - await this.unarchiveChannel(Alert.unarchivePublicChannelTitle, {confirm}); - }; - copyChannelHeader = async (headerText: string) => { // Long press on header text await element(by.text(headerText)).longPress(); diff --git a/detox/e2e/support/ui/screen/channel_settings.ts b/detox/e2e/support/ui/screen/channel_settings.ts new file mode 100644 index 000000000..98f2ba081 --- /dev/null +++ b/detox/e2e/support/ui/screen/channel_settings.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import { + Alert, +} from '@support/ui/component'; +import {timeouts, wait} from '@support/utils'; +import {expect, waitFor} from 'detox'; + +class ChannelSettingsScreen { + testID = { + channelSettingsScreen: 'channel_settings.screen', + closeButton: 'screen.back.button', + scrollView: 'channel_settings.scroll_view', + channelInfoOption: 'channel_settings.channel_info.option', + convertPrivateOption: 'channel_settings.convert_private.option', + archiveChannelOption: 'channel_settings.archive_channel.option', + unarchiveChannelOption: 'channel_settings.unarchive_channel.option', + }; + + channelSettingsScreen = element(by.id(this.testID.channelSettingsScreen)); + closeButton = element(by.id(this.testID.closeButton)); + scrollView = element(by.id(this.testID.scrollView)); + channelInfoOption = element(by.id(this.testID.channelInfoOption)); + convertPrivateOption = element(by.id(this.testID.convertPrivateOption)); + archiveChannelOption = element(by.id(this.testID.archiveChannelOption)); + unarchiveChannelOption = element(by.id(this.testID.unarchiveChannelOption)); + + toBeVisible = async () => { + await waitFor(this.channelSettingsScreen).toExist().withTimeout(timeouts.TEN_SEC); + + return this.channelSettingsScreen; + }; + + close = async () => { + await this.closeButton.tap(); + await expect(this.channelSettingsScreen).not.toBeVisible(); + }; + + archiveChannel = async (alertArchiveChannelTitle: Detox.NativeElement, {confirm = true} = {}) => { + await waitFor(this.archiveChannelOption).toBeVisible().whileElement(by.id(this.testID.scrollView)).scroll(50, 'down'); + await this.archiveChannelOption.tap({x: 1, y: 1}); + const { + noButton, + yesButton, + } = Alert; + await wait(timeouts.TWO_SEC); + await expect(alertArchiveChannelTitle).toBeVisible(); + await expect(noButton).toBeVisible(); + await expect(yesButton).toBeVisible(); + if (confirm) { + await yesButton.tap(); + await wait(timeouts.ONE_SEC); + await expect(this.channelSettingsScreen).not.toExist(); + } else { + await noButton.tap(); + await wait(timeouts.ONE_SEC); + await expect(this.channelSettingsScreen).toExist(); + } + }; + + archivePrivateChannel = async ({confirm = true} = {}) => { + await this.archiveChannel(Alert.archivePrivateChannelTitle, {confirm}); + }; + + archivePublicChannel = async ({confirm = true} = {}) => { + await this.archiveChannel(Alert.archivePublicChannelTitle, {confirm}); + }; + + convertToPrivateChannel = async (channelDisplayName: string, {confirm = true} = {}) => { + await this.scrollView.tap({x: 1, y: 1}); + await this.scrollView.scroll(100, 'down'); + await waitFor(this.convertPrivateOption).toBeVisible().whileElement(by.id(this.testID.scrollView)).scroll(50, 'down'); + await this.convertPrivateOption.tap({x: 1, y: 1}); + const { + channelNowPrivateTitle, + convertToPrivateChannelTitle, + noButton2, + okButton, + yesButton2, + } = Alert; + await expect(convertToPrivateChannelTitle(channelDisplayName)).toBeVisible(); + await expect(noButton2).toBeVisible(); + await expect(yesButton2).toBeVisible(); + if (confirm) { + await yesButton2.tap(); + await expect(channelNowPrivateTitle(channelDisplayName)).toBeVisible(); + await okButton.tap(); + await wait(timeouts.ONE_SEC); + await expect(this.channelSettingsScreen).toExist(); + } else { + await noButton2.tap(); + await wait(timeouts.ONE_SEC); + await expect(this.channelSettingsScreen).toExist(); + } + }; + + unarchiveChannel = async (alertUnarchiveChannelTitle: Detox.NativeElement, {confirm = true} = {}) => { + await waitFor(this.unarchiveChannelOption).toBeVisible().whileElement(by.id(this.testID.scrollView)).scroll(50, 'down'); + await wait(timeouts.TWO_SEC); + await this.unarchiveChannelOption.tap({x: 1, y: 1}); + const { + noButton, + yesButton, + } = Alert; + await expect(alertUnarchiveChannelTitle).toBeVisible(); + await expect(noButton).toBeVisible(); + await expect(yesButton).toBeVisible(); + if (confirm) { + await yesButton.tap(); + await wait(timeouts.ONE_SEC); + await expect(this.channelSettingsScreen).not.toExist(); + } else { + await noButton.tap(); + await wait(timeouts.ONE_SEC); + await expect(this.channelSettingsScreen).toExist(); + } + }; + + unarchivePrivateChannel = async ({confirm = true} = {}) => { + await this.unarchiveChannel(Alert.unarchivePrivateChannelTitle, {confirm}); + }; + + unarchivePublicChannel = async ({confirm = true} = {}) => { + await this.unarchiveChannel(Alert.unarchivePublicChannelTitle, {confirm}); + }; +} + +const channelSettingsScreen = new ChannelSettingsScreen(); +export default channelSettingsScreen; diff --git a/detox/e2e/support/ui/screen/create_or_edit_channel.ts b/detox/e2e/support/ui/screen/create_or_edit_channel.ts index 277b8c102..3a6599333 100644 --- a/detox/e2e/support/ui/screen/create_or_edit_channel.ts +++ b/detox/e2e/support/ui/screen/create_or_edit_channel.ts @@ -5,6 +5,7 @@ import { ChannelInfoScreen, ChannelScreen, ChannelListScreen, + ChannelSettingsScreen, } from '@support/ui/screen'; import {timeouts} from '@support/utils'; import {expect} from 'detox'; @@ -57,10 +58,10 @@ class CreateOrEditChannelScreen { }; openEditChannel = async () => { - // # Open edit channel screen - await ChannelInfoScreen.scrollView.tap({x: 1, y: 1}); - await ChannelInfoScreen.scrollView.scrollTo('bottom'); - await ChannelInfoScreen.editChannelOption.tap(); + // # Open edit channel screen (Channel Info > Channel Settings > Channel info) + await ChannelInfoScreen.openChannelSettings(); + await ChannelSettingsScreen.toBeVisible(); + await ChannelSettingsScreen.channelInfoOption.tap({x: 1, y: 1}); return this.toBeVisible(); }; diff --git a/detox/e2e/support/ui/screen/index.ts b/detox/e2e/support/ui/screen/index.ts index 3e0a4e9cf..54edbdcf1 100644 --- a/detox/e2e/support/ui/screen/index.ts +++ b/detox/e2e/support/ui/screen/index.ts @@ -11,6 +11,7 @@ import ChannelScreen from './channel'; import ChannelDropdownMenuScreen from './channel_dropdown_menu'; import ChannelInfoScreen from './channel_info'; import ChannelListScreen from './channel_list'; +import ChannelSettingsScreen from './channel_settings'; import ClockDisplaySettingsScreen from './clock_display_settings'; import CreateDirectMessageScreen from './create_direct_message'; import CreateOrEditChannelScreen from './create_or_edit_channel'; @@ -64,6 +65,7 @@ export { ChannelDropdownMenuScreen, ChannelInfoScreen, ChannelListScreen, + ChannelSettingsScreen, ClockDisplaySettingsScreen, CreateDirectMessageScreen, CreateOrEditChannelScreen, diff --git a/detox/e2e/test/products/channels/channels/archive_channel.e2e.ts b/detox/e2e/test/products/channels/channels/archive_channel.e2e.ts index 9bfda14af..639e582c1 100644 --- a/detox/e2e/test/products/channels/channels/archive_channel.e2e.ts +++ b/detox/e2e/test/products/channels/channels/archive_channel.e2e.ts @@ -23,6 +23,7 @@ import { LoginScreen, ServerScreen, ChannelInfoScreen, + ChannelSettingsScreen, } from '@support/ui/screen'; import {timeouts, wait} from '@support/utils'; import {expect} from 'detox'; @@ -54,14 +55,16 @@ describe('Channels - Archive Channel', () => { }); it('MM-T4932_1 - should be able to archive a public channel and confirm', async () => { - // # Open a public channel screen, open channel info screen, and tap on archive channel option and confirm + // # Open a public channel screen, open channel info screen, go to channel settings, and tap on archive channel option and confirm const {channel: publicChannel} = await Channel.apiCreateChannel(siteOneUrl, {type: 'O', teamId: testTeam.id}); await Channel.apiAddUserToChannel(siteOneUrl, testUser.id, publicChannel.id); await wait(timeouts.TWO_SEC); await device.reloadReactNative(); await ChannelScreen.open(channelsCategory, publicChannel.name); await ChannelInfoScreen.open(); - await ChannelInfoScreen.archivePublicChannel({confirm: true}); + await ChannelInfoScreen.openChannelSettings(); + await ChannelSettingsScreen.toBeVisible(); + await ChannelSettingsScreen.archivePublicChannel({confirm: true}); // # Tap on close channel button, open browse channels screen, search for the archived public channel await BrowseChannelsScreen.open(); @@ -76,32 +79,37 @@ describe('Channels - Archive Channel', () => { }); it('MM-T4932_2 - should be able to archive a public channel and cancel', async () => { - // # Open a public channel screen, open channel info screen, and tap on archive channel option and cancel + // # Open a public channel screen, open channel info screen, go to channel settings, and tap on archive channel option and cancel const {channel: publicChannel} = await Channel.apiCreateChannel(siteOneUrl, {type: 'O', teamId: testTeam.id}); await Channel.apiAddUserToChannel(siteOneUrl, testUser.id, publicChannel.id); await wait(timeouts.TWO_SEC); await device.reloadReactNative(); await ChannelScreen.open(channelsCategory, publicChannel.name); await ChannelInfoScreen.open(); - await ChannelInfoScreen.archivePublicChannel({confirm: false}); + await ChannelInfoScreen.openChannelSettings(); + await ChannelSettingsScreen.toBeVisible(); + await ChannelSettingsScreen.archivePublicChannel({confirm: false}); - // * Verify still on channel info screen - await ChannelInfoScreen.toBeVisible(); + // * Verify still on channel settings screen + await ChannelSettingsScreen.toBeVisible(); // # Go back to channel list screen + await ChannelSettingsScreen.close(); await ChannelInfoScreen.close(); await ChannelScreen.back(); }); it('MM-T4932_3 - should be able to archive a private channel and confirm', async () => { - // # Open a private channel screen, open channel info screen, and tap on archive channel option and confirm + // # Open a private channel screen, open channel info screen, go to channel settings, and tap on archive channel option and confirm const {channel: privateChannel} = await Channel.apiCreateChannel(siteOneUrl, {type: 'P', teamId: testTeam.id}); await Channel.apiAddUserToChannel(siteOneUrl, testUser.id, privateChannel.id); await wait(timeouts.TWO_SEC); await device.reloadReactNative(); await ChannelScreen.open(channelsCategory, privateChannel.name); await ChannelInfoScreen.open(); - await ChannelInfoScreen.archivePrivateChannel({confirm: true}); + await ChannelInfoScreen.openChannelSettings(); + await ChannelSettingsScreen.toBeVisible(); + await ChannelSettingsScreen.archivePrivateChannel({confirm: true}); // # Tap on close channel button, open browse channels screen, tap on channel dropdown, tap on archived channels menu item, and search for the archived private channel await BrowseChannelsScreen.open(); diff --git a/detox/e2e/test/products/channels/channels/channel_info.e2e.ts b/detox/e2e/test/products/channels/channels/channel_info.e2e.ts index 2ec1b8700..25095e648 100644 --- a/detox/e2e/test/products/channels/channels/channel_info.e2e.ts +++ b/detox/e2e/test/products/channels/channels/channel_info.e2e.ts @@ -19,6 +19,7 @@ import { LoginScreen, ServerScreen, ChannelInfoScreen, + ChannelSettingsScreen, } from '@support/ui/screen'; import {timeouts, wait} from '@support/utils'; import {expect} from 'detox'; @@ -65,17 +66,34 @@ describe('Channels - Channel Info', () => { await expect(ChannelInfoScreen.pinnedMessagesOption).toBeVisible(); await expect(ChannelInfoScreen.copyChannelLinkOption).toBeVisible(); await ChannelInfoScreen.scrollView.scrollTo('bottom'); - await expect(ChannelInfoScreen.editChannelOption).toBeVisible(); + await expect(ChannelInfoScreen.channelSettingsOption).toBeVisible(); await ChannelInfoScreen.scrollView.scrollTo('bottom'); await expect(ChannelInfoScreen.leaveChannelOption).toBeVisible(); - await waitFor(ChannelInfoScreen.archiveChannelOption).toBeVisible().whileElement(by.id(ChannelInfoScreen.testID.scrollView)).scroll(50, 'down'); - await expect(ChannelInfoScreen.archiveChannelOption).toBeVisible(); // # Go back to channel list screen await ChannelInfoScreen.close(); await ChannelScreen.back(); }); + it('should match elements on channel settings screen', async () => { + // # Open a channel screen, open channel info screen, and open channel settings screen + await ChannelScreen.open(channelsCategory, testChannel.name); + await ChannelInfoScreen.open(); + await ChannelInfoScreen.openChannelSettings(); + + // * Verify basic elements on channel settings screen + await ChannelSettingsScreen.toBeVisible(); + await expect(ChannelSettingsScreen.closeButton).toBeVisible(); + await expect(ChannelSettingsScreen.channelInfoOption).toBeVisible(); + await expect(ChannelSettingsScreen.convertPrivateOption).toBeVisible(); + await expect(ChannelSettingsScreen.archiveChannelOption).toBeVisible(); + + // # Go back to channel list screen + await ChannelSettingsScreen.close(); + await ChannelInfoScreen.close(); + await ChannelScreen.back(); + }); + it('MM-T4928_2 - should be able to view channel info by tapping intro channel info action', async () => { // # Open a channel screen and tap on intro channel info action await ChannelScreen.open(channelsCategory, testChannel.name); diff --git a/detox/e2e/test/products/channels/channels/convert_to_private_channel.e2e.ts b/detox/e2e/test/products/channels/channels/convert_to_private_channel.e2e.ts index dc62a0530..39328fe71 100644 --- a/detox/e2e/test/products/channels/channels/convert_to_private_channel.e2e.ts +++ b/detox/e2e/test/products/channels/channels/convert_to_private_channel.e2e.ts @@ -16,6 +16,7 @@ import { LoginScreen, ServerScreen, ChannelInfoScreen, + ChannelSettingsScreen, } from '@support/ui/screen'; import {getAdminAccount, getRandomId, timeouts, wait} from '@support/utils'; import {expect} from 'detox'; @@ -42,7 +43,7 @@ describe('Channels - Convert to Private Channel', () => { }); it('MM-T4972_1 - should be able to convert public channel to private and confirm', async () => { - // # Create a public channel screen, open channel info screen, and tap on convert to private channel option and confirm + // # Create a public channel screen, open channel info screen, go to channel settings, and tap on convert to private channel option and confirm const channelDisplayName = `Channel ${getRandomId()}`; await CreateOrEditChannelScreen.openCreateChannel(); await CreateOrEditChannelScreen.displayNameInput.replaceText(channelDisplayName); @@ -50,31 +51,37 @@ describe('Channels - Convert to Private Channel', () => { await wait(timeouts.TWO_SEC); await ChannelScreen.scheduledPostTooltipCloseButtonAdminAccount.tap(); await ChannelInfoScreen.open(); - await ChannelInfoScreen.convertToPrivateChannel(channelDisplayName, {confirm: true}); + await ChannelInfoScreen.openChannelSettings(); + await ChannelSettingsScreen.toBeVisible(); + await ChannelSettingsScreen.convertToPrivateChannel(channelDisplayName, {confirm: true}); - // * Verify on channel info screen and convert to private channel option does not exist - await ChannelInfoScreen.toBeVisible(); - await expect(ChannelInfoScreen.convertPrivateOption).not.toExist(); + // * Verify on channel settings screen and convert to private channel option does not exist + await ChannelSettingsScreen.toBeVisible(); + await expect(ChannelSettingsScreen.convertPrivateOption).not.toExist(); // # Go back to channel list screen + await ChannelSettingsScreen.close(); await ChannelInfoScreen.close(); await ChannelScreen.back(); }); it('MM-T4972_2 - should be able to convert public channel to private and cancel', async () => { - // # Create a public channel screen, open channel info screen, and tap on convert to private channel option and cancel + // # Create a public channel screen, open channel info screen, go to channel settings, and tap on convert to private channel option and cancel const channelDisplayName = `Channel ${getRandomId()}`; await CreateOrEditChannelScreen.openCreateChannel(); await CreateOrEditChannelScreen.displayNameInput.replaceText(channelDisplayName); await CreateOrEditChannelScreen.createButton.tap(); await ChannelInfoScreen.open(); - await ChannelInfoScreen.convertToPrivateChannel(channelDisplayName, {confirm: false}); + await ChannelInfoScreen.openChannelSettings(); + await ChannelSettingsScreen.toBeVisible(); + await ChannelSettingsScreen.convertToPrivateChannel(channelDisplayName, {confirm: false}); - // * Verify on channel info screen and convert to private channel option still exists - await ChannelInfoScreen.toBeVisible(); - await expect(ChannelInfoScreen.convertPrivateOption).toExist(); + // * Verify on channel settings screen and convert to private channel option still exists + await ChannelSettingsScreen.toBeVisible(); + await expect(ChannelSettingsScreen.convertPrivateOption).toExist(); // # Go back to channel list screen + await ChannelSettingsScreen.close(); await ChannelInfoScreen.close(); await ChannelScreen.back(); }); diff --git a/detox/e2e/test/products/channels/channels/edit_channel.e2e.ts b/detox/e2e/test/products/channels/channels/edit_channel.e2e.ts index 9a3878ced..24c331038 100644 --- a/detox/e2e/test/products/channels/channels/edit_channel.e2e.ts +++ b/detox/e2e/test/products/channels/channels/edit_channel.e2e.ts @@ -25,6 +25,7 @@ import { HomeScreen, LoginScreen, ServerScreen, + ChannelSettingsScreen, } from '@support/ui/screen'; import {isAndroid, isIos, timeouts, wait} from '@support/utils'; import {expect} from 'detox'; @@ -81,8 +82,9 @@ describe('Channels - Edit Channel', () => { await expect(CreateOrEditChannelScreen.headerInput).toBeVisible(); await expect(CreateOrEditChannelScreen.headerDescription).toHaveText('Specify text to appear in the channel header beside the channel name. For example, include frequently used links by typing link text [Link Title](http://example.com).'); - // # Go back to channel screen + // # Go back to channel screen (CreateOrEditChannel back goes to Channel Settings, then close to Channel Info, then close Channel Info) await CreateOrEditChannelScreen.back(); + await ChannelSettingsScreen.close(); await ChannelInfoScreen.close(); }); @@ -108,7 +110,9 @@ describe('Channels - Edit Channel', () => { await CreateOrEditChannelScreen.headerInput.typeText('\nheader1\nheader2'); await CreateOrEditChannelScreen.saveButton.tap(); - // * Verify on channel info screen and changes have been saved + // * Verify on channel info screen and changes have been saved (back from CreateOrEditChannel lands on Channel Settings, close to get to Channel Info) + await ChannelSettingsScreen.toBeVisible(); + await ChannelSettingsScreen.close(); await ChannelInfoScreen.toBeVisible(); await expect(ChannelInfoScreen.publicPrivateTitleDisplayName).toHaveText(`${testChannel.display_name} name`); await expect(ChannelInfoScreen.publicPrivateTitlePurpose).toHaveText(`Channel purpose: ${testChannel.display_name.toLowerCase()} purpose`); diff --git a/detox/e2e/test/products/channels/channels/unarchive_channel.e2e.ts b/detox/e2e/test/products/channels/channels/unarchive_channel.e2e.ts index 1c42f2212..c3ea98fb7 100644 --- a/detox/e2e/test/products/channels/channels/unarchive_channel.e2e.ts +++ b/detox/e2e/test/products/channels/channels/unarchive_channel.e2e.ts @@ -17,6 +17,7 @@ import { LoginScreen, ServerScreen, ChannelInfoScreen, + ChannelSettingsScreen, } from '@support/ui/screen'; import {getAdminAccount, getRandomId, timeouts, wait} from '@support/utils'; import {expect} from 'detox'; @@ -54,7 +55,9 @@ describe('Channels - Unarchive Channel', () => { await expect(ChannelScreen.scheduledPostTooltipCloseButtonAdminAccount).toBeVisible(); await ChannelScreen.scheduledPostTooltipCloseButtonAdminAccount.tap(); await ChannelInfoScreen.open(); - await ChannelInfoScreen.archivePublicChannel({confirm: true}); + await ChannelInfoScreen.openChannelSettings(); + await ChannelSettingsScreen.toBeVisible(); + await ChannelSettingsScreen.archivePublicChannel({confirm: true}); // * Verify on public channel screen and archived post draft is displayed await ChannelListScreen.toBeVisible(); @@ -77,9 +80,11 @@ describe('Channels - Unarchive Channel', () => { // # Go back to channel list screen by closing archived channel - // # Open channel info screen, tap on unarchive channel and confirm, close and re-open app to reload, and re-open unarchived public channel + // # Open channel info screen, go to channel settings, tap on unarchive channel and confirm, close and re-open app to reload, and re-open unarchived public channel await ChannelInfoScreen.open(); - await ChannelInfoScreen.unarchivePublicChannel({confirm: true}); + await ChannelInfoScreen.openChannelSettings(); + await ChannelSettingsScreen.toBeVisible(); + await ChannelSettingsScreen.unarchivePublicChannel({confirm: true}); await wait(timeouts.FOUR_SEC); // * Verify on unarchived public channel screen and active post draft is displayed @@ -100,7 +105,9 @@ describe('Channels - Unarchive Channel', () => { await CreateOrEditChannelScreen.createButton.tap(); await wait(timeouts.FOUR_SEC); await ChannelInfoScreen.open(); - await ChannelInfoScreen.archivePrivateChannel({confirm: true}); + await ChannelInfoScreen.openChannelSettings(); + await ChannelSettingsScreen.toBeVisible(); + await ChannelSettingsScreen.archivePrivateChannel({confirm: true}); await ChannelListScreen.toBeVisible(); await FindChannelsScreen.open(); @@ -124,9 +131,11 @@ describe('Channels - Unarchive Channel', () => { // * Verify on private channel screen and archived post draft is displayed await expect(ChannelScreen.postDraftArchived).toBeVisible(); - // # Open channel info screen, tap on unarchive channel and confirm, close and re-open app to reload, and re-open unarchived private channel + // # Open channel info screen, go to channel settings, tap on unarchive channel and confirm, close and re-open app to reload, and re-open unarchived private channel await ChannelInfoScreen.open(); - await ChannelInfoScreen.unarchivePrivateChannel({confirm: true}); + await ChannelInfoScreen.openChannelSettings(); + await ChannelSettingsScreen.toBeVisible(); + await ChannelSettingsScreen.unarchivePrivateChannel({confirm: true}); await wait(timeouts.FOUR_SEC); // * Verify on unarchived private channel screen and active post draft is displayed diff --git a/detox/e2e/test/products/channels/smoke_test/channels.e2e.ts b/detox/e2e/test/products/channels/smoke_test/channels.e2e.ts index 27d4d9eac..1c4a8cc27 100644 --- a/detox/e2e/test/products/channels/smoke_test/channels.e2e.ts +++ b/detox/e2e/test/products/channels/smoke_test/channels.e2e.ts @@ -29,6 +29,7 @@ import { HomeScreen, LoginScreen, ServerScreen, + ChannelSettingsScreen, } from '@support/ui/screen'; import {getRandomId, timeouts, wait} from '@support/utils'; import {expect} from 'detox'; @@ -157,7 +158,9 @@ describe('Smoke Test - Channels', () => { await CreateOrEditChannelScreen.headerInput.typeText('\nheader1\nheader2'); await CreateOrEditChannelScreen.saveButton.tap(); - // * Verify on channel info screen and changes have been saved + // * Verify on channel info screen and changes have been saved (close channel settings first to return to channel info) + await ChannelSettingsScreen.toBeVisible(); + await ChannelSettingsScreen.close(); await ChannelInfoScreen.toBeVisible(); await expect(element(by.text(`Channel header: ${testChannel.display_name.toLowerCase()}\nheader1\nheader2`))).toBeVisible(); @@ -193,13 +196,15 @@ describe('Smoke Test - Channels', () => { }); it('MM-T4774_6 - should be able to archive and leave a channel', async () => { - // # Open a channel screen, open channel info screen, and tap on archive channel option and confirm + // # Open a channel screen, open channel info screen, go to channel settings, and tap on archive channel option and confirm const {channel} = await Channel.apiCreateChannel(siteOneUrl, {teamId: testTeam.id}); await Channel.apiAddUserToChannel(siteOneUrl, testUser.id, channel.id); await wait(timeouts.TWO_SEC); await device.reloadReactNative(); await ChannelScreen.open(channelsCategory, channel.name); await ChannelInfoScreen.open(); - await ChannelInfoScreen.archivePublicChannel({confirm: true}); + await ChannelInfoScreen.openChannelSettings(); + await ChannelSettingsScreen.toBeVisible(); + await ChannelSettingsScreen.archivePublicChannel({confirm: true}); }); }); diff --git a/docs/testing_guide.md b/docs/testing_guide.md new file mode 100644 index 000000000..4040206bf --- /dev/null +++ b/docs/testing_guide.md @@ -0,0 +1,65 @@ +# Unit testing guide + +This document describes how to add and structure unit tests in the Mattermost Mobile project. For high-level testing notes and patterns, see the **Testing** section in [CLAUDE.md](../CLAUDE.md). For concrete examples, see [app/products/playbooks](../app/products/playbooks), which is the canonical reference for test layout and patterns. + +## Where tests live + +- Tests are **co-located** with source files: `*.test.ts` or `*.test.tsx` next to the file under test (e.g. `channel.test.ts` beside `channel.ts`). +- Screen entry points often have an `index.test.tsx` that tests the wrapper and passes through to the main screen component. +- Reference: [app/products/playbooks](../app/products/playbooks) for the full pattern. + +## What to test by layer + +### Local actions (`app/actions/local/`) + +- Use a **real in-memory database**: `DatabaseManager.init([serverUrl])` in `beforeEach` and `DatabaseManager.destroyServerDatabase(serverUrl)` in `afterEach`. +- Use **TestHelper** for fake entities: `TestHelper.fakeChannel()`, `TestHelper.fakePost()`, `TestHelper.fakeChannelMember()`, etc. +- Cover: **not-found database** (invalid `serverUrl`), **not-found entity** (e.g. channel/post missing), **success path**, and **error path** (e.g. mock `database.write` or `operator.batchRecords` to throw). +- Example: [app/products/playbooks/actions/local/channel.test.ts](../app/products/playbooks/actions/local/channel.test.ts), [app/actions/local/post.test.ts](../app/actions/local/post.test.ts). + +### Remote actions (`app/actions/remote/`) + +- **Mock** `NetworkManager.getClient` in `beforeAll` to return a client with the methods used (e.g. `setChannelAutotranslation`, `setMyChannelAutotranslation`). +- Assert the client was called with the expected arguments and that the action returns `{data}` on success and `{error}` on failure. +- On error, assert `forceLogoutIfNecessary` (and any logging) is called when applicable. +- Example: [app/products/playbooks/actions/remote/playbooks.test.ts](../app/products/playbooks/actions/remote/playbooks.test.ts), [app/actions/remote/channel.test.ts](../app/actions/remote/channel.test.ts). + +### Queries (`app/queries/servers/`) + +- For **query** functions that return a WatermelonDB `Query`: use a real DB, create the relevant records with the operator, run the query, and assert on the fetched results. +- For **observe** functions that return RxJS observables (canonical pattern from playbooks): + 1. Use a **real database**: `DatabaseManager.init([serverUrl])` in `beforeEach`, destroy in `afterEach`. + 2. **Set up data** in the DB with the operator (e.g. `handleChannel`, `handleMyChannel`, `handleSystem`) so the observe under test has real records to emit. + 3. If the observe function depends on other observables from a **mocked module** (e.g. `observeConfigBooleanValue` from `./system`), mock that dependency to return an Observable (e.g. `jest.mocked(observeConfigBooleanValue).mockReturnValue(of$(true))`) so `combineLatest` and similar operators receive valid observables. + 4. Call the observe function with `database` (and any ids), then **subscribe** with a spy: `const subscriptionNext = jest.fn(); result.subscribe({ next: subscriptionNext });` + 5. **Assert** the emitted value: `expect(subscriptionNext).toHaveBeenCalledWith(expectedValue)`. +- You can also use `firstValueFrom(observable)` to await the first emission when that is clearer. +- Example: [app/products/playbooks/database/queries/run.test.ts](../app/products/playbooks/database/queries/run.test.ts) (`observePlaybookRunById`, `observePlaybookRunProgress`), [app/products/playbooks/database/queries/version.test.ts](../app/products/playbooks/database/queries/version.test.ts) (`observeIsPlaybooksEnabled`), [app/queries/servers/channel.test.ts](../app/queries/servers/channel.test.ts) (`observeChannelAutotranslation`, `observeMyChannelAutotranslation`, `observeIsChannelAutotranslated`). + +### Screens and UI + +- Use **`renderWithEverything`** from `@test/intl-test-helper` when the component needs database or server URL (pass `{database}` and optionally `serverUrl`). Use **`renderWithIntlAndTheme`** when it only needs theme/intl (no DB). +- Mock heavy or external deps: navigation, remote actions, `useServerUrl`, etc., with `jest.mock(...)`. Mock child components with `mockImplementation((props) => React.createElement('ComponentName', { testID: '...', ...props }))` so they render with a stable `testID` and forward props. +- **Asserting on props passed to children:** Query the **screen** for the rendered element (e.g. `getByTestId('...')` or `getAllByTestId('...')`), then assert with `expect(element).toHaveProp('propName', value)`. **Do not** use `jest.mocked(Component).mock.calls` or `mock.calls[0][0]` to inspect props—assert on what is in the tree, as in playbooks screen tests. +- Assert that key elements render (e.g. by `testID`) and that user actions (e.g. toggle, button press) call the expected handlers or actions. +- Use `fireEvent.press()` for button/toggle interactions; wrap async updates in `act()` or `waitFor` when needed. +- Use a **`getBaseProps()`** helper typed as `ComponentProps` for default props and `beforeEach(() => jest.clearAllMocks())` where appropriate. +- Example: [app/products/playbooks/screens/select_user/select_user.test.tsx](../app/products/playbooks/screens/select_user/select_user.test.tsx), [app/screens/show_translation/show_translation.test.tsx](../app/screens/show_translation/show_translation.test.tsx). + +### Client REST (`app/client/rest/`) + +- Create the client (e.g. `TestHelper.createClient()`), then mock `client.doFetch`. +- Assert the request URL (and query string if any) and options (method, body, etc.), and that the return value or thrown error matches expectations. +- Example: [app/products/playbooks/client/rest.test.ts](../app/products/playbooks/client/rest.test.ts), [app/client/rest/channels.test.ts](../app/client/rest/channels.test.ts). + +## Helpers and setup + +- **TestHelper** (`test/test_helper.ts`): `fakeChannel`, `fakePost`, `fakeUser`, `fakeChannelMember`, etc.; `createClient()` for REST client tests. +- **Database**: `DatabaseManager.init([serverUrl])` and `DatabaseManager.destroyServerDatabase(serverUrl)`; get `database` and `operator` from `DatabaseManager.getServerDatabaseAndOperator(serverUrl)`. +- **Rendering**: `renderWithEverything(ui, { database, serverUrl })` when the component needs DB or server context. +- **Events**: `DeviceEventEmitter.addListener(Events.SOME_EVENT, callback)` to assert that an event was emitted; call `listener.remove()` in cleanup. + +## Jest configuration + +- Setup: `test/setup.ts` is run before tests; avoid adding one-off mocks there. +- Coverage: `coveragePathIgnorePatterns` in `jest.config.js` exclude `/components/` and `/screens/`, but tests for critical screens and components are still encouraged for regression safety. diff --git a/index.ts b/index.ts index 27ac7b193..2ca1e1c4e 100644 --- a/index.ts +++ b/index.ts @@ -49,6 +49,7 @@ if (global.HermesInternal) { require('@formatjs/intl-datetimeformat/add-all-tz'); require('@formatjs/intl-listformat/polyfill-force'); require('@formatjs/intl-relativetimeformat/polyfill-force'); + require('@formatjs/intl-displaynames/polyfill-force'); } if (Platform.OS === 'android') { diff --git a/package-lock.json b/package-lock.json index 85cfdc1b5..ec0d8138e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "license": "Apache 2.0", "dependencies": { "@formatjs/intl-datetimeformat": "6.18.0", + "@formatjs/intl-displaynames": "6.8.13", "@formatjs/intl-getcanonicallocales": "2.5.5", "@formatjs/intl-listformat": "7.7.11", "@formatjs/intl-locale": "4.2.11", @@ -20,7 +21,7 @@ "@gorhom/bottom-sheet": "5.1.2", "@gorhom/portal": "1.0.14", "@mattermost/calls": "github:mattermost/calls-common#ab53c24053b89e4d3d853bbe1244892899517f45", - "@mattermost/compass-icons": "0.1.48", + "@mattermost/compass-icons": "0.1.53", "@mattermost/hardware-keyboard": "file:./libraries/@mattermost/hardware-keyboard", "@mattermost/react-native-emm": "1.6.2", "@mattermost/react-native-network-client": "1.9.1", @@ -4094,6 +4095,38 @@ "tslib": "^2.8.0" } }, + "node_modules/@formatjs/intl-displaynames": { + "version": "6.8.13", + "resolved": "https://registry.npmjs.org/@formatjs/intl-displaynames/-/intl-displaynames-6.8.13.tgz", + "integrity": "sha512-VbY7BdYJX5eURVKLk2grndUQtnbCLNbcJId/Sb/PsX7fWXiqWvg7qt/mecVHRzqoSEoGCQToKDxzpJj8RC0s3g==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/intl-localematcher": "0.6.2", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/intl-displaynames/node_modules/@formatjs/ecma402-abstract": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", + "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/intl-localematcher": "0.6.2", + "decimal.js": "^10.4.3", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/intl-displaynames/node_modules/@formatjs/intl-localematcher": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz", + "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@formatjs/intl-enumerator": { "version": "1.8.10", "resolved": "https://registry.npmjs.org/@formatjs/intl-enumerator/-/intl-enumerator-1.8.10.tgz", @@ -4828,9 +4861,10 @@ } }, "node_modules/@mattermost/compass-icons": { - "version": "0.1.48", - "resolved": "https://registry.npmjs.org/@mattermost/compass-icons/-/compass-icons-0.1.48.tgz", - "integrity": "sha512-z36LlyZryL+Ssc1pbCGE1bidRmdcYlHGkVtcFP9qpd7+Jpk/sRiv0OjCMzcBHDnCiVT8jIfSmtNrTd+TJdVnmQ==" + "version": "0.1.53", + "resolved": "https://registry.npmjs.org/@mattermost/compass-icons/-/compass-icons-0.1.53.tgz", + "integrity": "sha512-MPcwJ9bKWxOxppxoqYCK5BW/a9qkaozxQr/fTdu55TLBjV5t0W6EPVa+msnFLr2iStYsYVnHwEo3fArsX0Bnew==", + "license": "MIT" }, "node_modules/@mattermost/hardware-keyboard": { "resolved": "libraries/@mattermost/hardware-keyboard", diff --git a/package.json b/package.json index 9f6db6b9f..2ccd04659 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@formatjs/intl-datetimeformat": "6.18.0", + "@formatjs/intl-displaynames": "6.8.13", "@formatjs/intl-getcanonicallocales": "2.5.5", "@formatjs/intl-listformat": "7.7.11", "@formatjs/intl-locale": "4.2.11", @@ -21,7 +22,7 @@ "@gorhom/bottom-sheet": "5.1.2", "@gorhom/portal": "1.0.14", "@mattermost/calls": "github:mattermost/calls-common#ab53c24053b89e4d3d853bbe1244892899517f45", - "@mattermost/compass-icons": "0.1.48", + "@mattermost/compass-icons": "0.1.53", "@mattermost/hardware-keyboard": "file:./libraries/@mattermost/hardware-keyboard", "@mattermost/react-native-emm": "1.6.2", "@mattermost/react-native-network-client": "1.9.1", diff --git a/types/api/websocket.d.ts b/types/api/websocket.d.ts index 72dd6e3ff..b0452081a 100644 --- a/types/api/websocket.d.ts +++ b/types/api/websocket.d.ts @@ -21,3 +21,11 @@ type ThreadReadChangedData = { unread_mentions: number; unread_replies: number; }; + +type PostTranslationUpdateData = { + language: string; + object_id: string; + src_lang: string; + state: PostTranslationState; + translation: string; // JSON-encoded PostTranslation['object'] +}