Add autotranslations (#9324) (#9500)

* Add autotranslations

* Develop latest changes and missing features

* i18n-extract

* Fix tests and update api behavior

* Fix minor bugs

* adjustments to shimmer animation, showtranslation modal style

* Update post.tsx

* Update show_translation.tsx

* adjust sizing and opacity of translate icon

* Add channel header translated icon

* Disable auto translation item if it is not a supported language

* Move channel info to the top

* Update edit channel to channel info

* Fix align of option items

* Fix i18n

* Add detox related changes for channel settings changes

* Add tests

* Address changes around the my channel column change

* Address feedback

* Fix test

* Fix bad import

* Fix set my channel autotranslation

* Fix test

* Address feedback

* Add missing files from last commit

* Remove unneeded change

---------


(cherry picked from commit 23565b5135)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>
This commit is contained in:
Mattermost Build 2026-02-11 08:09:39 +02:00 committed by GitHub
parent 3edc6c51ab
commit e8f1d0c729
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
105 changed files with 3375 additions and 463 deletions

View file

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

View file

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

View file

@ -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: []};
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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([

View file

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

View file

@ -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<MyUserRequest> => {
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};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,6 +19,8 @@ export interface ClientChannelsMix {
updateChannelPrivacy: (channelId: string, privacy: any) => Promise<Channel>;
patchChannel: (channelId: string, channelPatch: Partial<Channel>) => Promise<Channel>;
updateChannelNotifyProps: (props: ChannelNotifyProps & {channel_id: string; user_id: string}) => Promise<any>;
setChannelAutotranslation: (channelId: string, enabled: boolean) => Promise<Channel>;
setMyChannelAutotranslation: (channelId: string, enabled: boolean) => Promise<ChannelMembership>;
getChannel: (channelId: string, groupLabel?: RequestGroupLabel) => Promise<Channel>;
getChannelByName: (teamId: string, channelName: string, includeDeleted?: boolean) => Promise<Channel>;
getChannelByNameAndTeamName: (teamName: string, channelName: string, includeDeleted?: boolean) => Promise<Channel>;
@ -157,6 +159,17 @@ const ClientChannels = <TBase extends Constructor<ClientBase>>(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),

View file

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

View file

@ -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 (
<BaseOption
message={messages.showTranslation}
iconName='translate'
onPress={onHandlePress}
testID='post_options.show_translation.option'
/>
);
};
export default ShowTranslationOption;

View file

@ -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 = ({
>
<View style={styles.centered}>
{!hasSearch &&
<Animated.Text
ellipsizeMode='tail'
numberOfLines={1}
style={[styles.title, opacity]}
testID='navigation.header.title'
>
{title}
</Animated.Text>
<View style={styles.titleRow}>
<Animated.Text
ellipsizeMode='tail'
numberOfLines={1}
style={[styles.title, opacity]}
testID='navigation.header.title'
>
{title}
</Animated.Text>
{titleCompanion}
</View>
}
{!isLargeTitle && Boolean(subtitle || subtitleCompanion) &&
<View style={styles.subtitleContainer}>

View file

@ -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<SearchRef, Props>(({
subtitle,
subtitleCompanion,
title = '',
titleCompanion,
hideHeader,
...searchProps
}: Props, ref) => {
@ -101,6 +103,7 @@ const NavigationHeader = forwardRef<SearchRef, Props>(({
subtitleCompanion={subtitleCompanion}
theme={theme}
title={title}
titleCompanion={titleCompanion}
/>
{isLargeTitle &&
<NavigationHeaderLargeTitle

View file

@ -57,6 +57,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
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<React.SetStateAction<string | boolean>>)|((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 = (
<View
testID={testID}
style={styles.container}
style={containerStyle}
onLayout={onLayout}
>
<View style={labelContainerStyle}>

View file

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

View file

@ -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 = (
<View style={style.messageBody}>

View file

@ -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<number|undefined>();
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 (
<>
<Animated.View style={animatedStyle}>
@ -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}

View file

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

View file

@ -46,6 +46,7 @@ describe('Header', () => {
teammateNameDisplay: '',
hideGuestTags: false,
currentUser,
isChannelAutotranslated: false,
};
it('Should show BoR icon for own BoR post', () => {

View file

@ -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 (
<>
<View style={[style.container, pendingPostStyle]}>
@ -141,6 +163,9 @@ const Header = (props: HeaderProps) => {
style={style.time}
testID='post_header.date_time'
/>
{isChannelAutotranslated && post.type === '' &&
<TranslateIcon translationState={translation?.state}/>
}
{isEphemeral && (
<FormattedText
id='post_header.visible_message'

View file

@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import Loading from '@components/loading';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
translationState?: PostTranslation['state'];
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
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 (
<CompassIcon
name='translate'
size={14}
color={changeOpacity(theme.centerChannelColor, 0.64)}
/>
);
}
if (translationState === 'processing') {
return (
<>
<Loading
size='small'
color={changeOpacity(theme.centerChannelColor, 0.64)}
/>
<FormattedText
id='post_header.translation_processing'
defaultMessage='Translating...'
style={style.translationProcessing}
/>
</>
);
}
if (translationState === 'unavailable') {
return (
<CompassIcon
name='translate'
size={14}
color={theme.dndIndicator}
/>
);
}
return null;
}
export default TranslateIcon;

View file

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

View file

@ -41,6 +41,7 @@ describe('performance metrics', () => {
location: 'Channel',
post,
isLastPost: true,
isChannelAutotranslated: false,
};
}

View file

@ -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<ViewStyle>;
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 = ({
<View
testID={testID}
style={[styles.postStyle, style, highlightedStyle]}
onLayout={onLayout}
>
<TouchableHighlight
testID={itemTestID}
@ -463,6 +476,7 @@ const Post = ({
</View>
</>
</TouchableHighlight>
<ShimmerAnimation {...shimmerAnimationProps}/>
</View>
);
};

View file

@ -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<typeof Animated.View>['style'];
gradientColors: ComponentProps<typeof LinearGradient>['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 (
<View
style={shimmerStyles.shimmerContainer}
pointerEvents='none'
>
<View style={[shimmerStyles.shimmerBackground, {backgroundColor}]}/>
<Animated.View style={[shimmerStyles.shimmerWrapper, shimmerAnimatedStyle]}>
<LinearGradient
colors={gradientColors}
locations={gradientSettings.locations}
start={gradientSettings.start}
end={gradientSettings.end}
style={StyleSheet.absoluteFill}
/>
</Animated.View>
</View>
);
};
export default ShimmerAnimation;

View file

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

View file

@ -79,6 +79,7 @@ describe('components/post_list/PostList', () => {
savedPostIds: new Set(),
testID: 'post_list',
shouldShowJoinLeaveMessages: false,
isChannelAutotranslated: false,
listRef: createRef<FlatList<string | PostModel>>(),
};

View file

@ -57,6 +57,7 @@ type Props = {
testID: string;
currentCallBarVisible?: boolean;
savedPostIds: Set<string>;
isChannelAutotranslated: boolean;
listRef?: React.RefObject<FlatList<string | PostModel>>;
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(() => {

View file

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

View file

@ -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 (
<View style={styles.container}>
<ChannelInfo
@ -59,6 +71,7 @@ function PostWithChannelInfo({appsEnabled, customEmojiNames, isCRTEnabled, post,
nextPost={undefined}
testID={`${testID}.post`}
isSaved={isSaved}
isChannelAutotranslated={isChannelAutotranslated}
/>
</View>
</View>

View file

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

View file

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

View file

@ -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<string, SnackBarConfig> = {
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,
},
};

View file

@ -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`,

View file

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

View file

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

View file

@ -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<Record<string, ChannelModel>>((map, channel) => {
map[channel.id] = channel;
return map;
const storedChannelsMap = channelsQuery.reduce<Record<string, ChannelModel>>((acc, channel) => {
acc[channel.id] = channel;
return acc;
}, {});
const channelInfosQuery = await queryAllChannelsInfo(database);
const storedChannelInfosMap = channelInfosQuery.reduce<Record<string, ChannelInfoModel>>((map, info) => {
map[info.id] = info;
return map;
const storedChannelInfosMap = channelInfosQuery.reduce<Record<string, ChannelInfoModel>>((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<ChannelModel>(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<MyChannelModel>(MY_CHANNEL).query(
Q.where('id', Q.oneOf(ids)),
);
};
export const queryMyChannelsWithAutotranslation = (database: Database) => {
return database.get<MyChannelModel>(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<ChannelInfoModel>(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)));
};

View file

@ -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<Model[]>} 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<Model[]> {
await handleAutotranslationChanges(serverUrl, meData, chData);
const modelPromises = await prepareEntryModels({operator, teamData, chData, prefData, meData, isCRTEnabled});
const flattenModels = (await Promise.all(modelPromises)).flat();

View file

@ -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(),
);
};

View file

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

View file

@ -54,6 +54,7 @@ describe('ChannelHeader', () => {
isOwnDirectMessage: false,
shouldRenderChannelBanner: false,
isPlaybooksEnabled: true,
isChannelAutotranslated: false,
};
}

View file

@ -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 (
<CompassIcon
name='translate'
size={16}
color={changeOpacity(theme.sidebarHeaderTextColor, 0.72)}
/>
);
}
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}
/>
<View style={contextStyle}>
<RoundedHeaderContext/>

View file

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

View file

@ -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 (
<View
style={styles.flex}
@ -137,28 +129,14 @@ const ChannelInfo = ({
<Options
channelId={channelId}
type={type}
callsEnabled={callsAvailable}
callsEnabled={isCallsEnabledInChannel}
canManageMembers={canManageMembers}
isCRTEnabled={isCRTEnabled}
canManageSettings={canManageSettings}
isPlaybooksEnabled={isPlaybooksEnabled}
hasChannelSettingsActions={hasChannelSettingsActions}
isAutotranslationEnabledForThisChannel={isAutotranslationEnabledForThisChannel}
/>
<View style={styles.separator}/>
{convertGMOptionAvailable &&
<>
<ConvertToChannelLabel channelId={channelId}/>
<View style={styles.separator}/>
</>
}
{canEnableDisableCalls &&
<>
<ChannelInfoEnableCalls
channelId={channelId}
enabled={isCallsEnabledInChannel}
/>
<View style={styles.separator}/>
</>
}
<ChannelInfoAppBindings
channelId={channelId}
serverUrl={serverUrl}
@ -166,8 +144,6 @@ const ChannelInfo = ({
/>
<DestructiveOptions
channelId={channelId}
componentId={componentId}
type={type}
/>
</ScrollView>
</SafeAreaView>

View file

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

View file

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

View file

@ -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 &&
<ConvertPrivate channelId={channelId}/>
}
<LeaveChannelLabel
channelId={channelId}
isOptionItem={true}
testID='channel_info.options.leave_channel.option'
/>
{type !== General.DM_CHANNEL && type !== General.GM_CHANNEL &&
<Archive
channelId={channelId}
componentId={componentId}
type={type}
/>
}
</>
);
};

View file

@ -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<string>,
channel: Observable<ChannelModel | undefined>,
currentUser: Observable<UserModel | undefined>,
type: Observable<ChannelType | undefined>,
) => {
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,
};
});

View file

@ -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 (
<OptionItem
action={goToChannelSettings}
label={title}
icon='settings-outline'
type={Platform.select({ios: 'arrow', default: 'default'})}
testID='channel_info.options.channel_settings.option'
/>
);
};
export default ChannelSettings;

View file

@ -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(<ChannelInfoOptions {...props}/>);
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(<ChannelInfoOptions {...props}/>, {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(<ChannelInfoOptions {...props}/>, {database});
expect(getByTestId('my-autotranslation-option')).toBeTruthy();
});
});

View file

@ -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 && (
<ChannelSettings channelId={channelId}/>
)}
{type !== General.DM_CHANNEL && (
<>
{isCRTEnabled && (
@ -49,6 +55,9 @@ const Options = ({
</>
)}
<NotificationPreference channelId={channelId}/>
{isAutotranslationEnabledForThisChannel && (
<MyAutotranslation channelId={channelId}/>
)}
<PinnedMessages channelId={channelId}/>
<ChannelFiles channelId={channelId}/>
{isPlaybooksEnabled && !isDMorGM &&
@ -69,9 +78,6 @@ const Options = ({
testID='channel_info.options.copy_channel_link.option'
/>
}
{canManageSettings &&
<EditChannel channelId={channelId}/>
}
</>
);
};

View file

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

View file

@ -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(
<MyAutotranslation
channelId='channel1'
enabled={false}
displayName='Test Channel'
isLanguageSupported={false}
/>,
{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(
<MyAutotranslation
channelId='channel1'
enabled={true}
displayName='Test Channel'
isLanguageSupported={true}
/>,
{database},
);
expect(getByTestId('channel_info.options.my_autotranslation.option.toggled.true')).toBeTruthy();
});
it('renders toggle with correct testID when disabled', () => {
const {getByTestId} = renderWithEverything(
<MyAutotranslation
channelId='channel1'
enabled={false}
displayName='Test Channel'
isLanguageSupported={true}
/>,
{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(
<MyAutotranslation
channelId='channel1'
enabled={false}
displayName='Test Channel'
isLanguageSupported={true}
/>,
{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(
<MyAutotranslation
channelId='channel1'
enabled={true}
displayName='Test Channel'
isLanguageSupported={true}
/>,
{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 wont 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);
});
});
});

View file

@ -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 (
<OptionItem
label={intl.formatMessage(messages.label)}
description={intl.formatMessage(messages.languageNotSupported)}
icon='translate'
type='none'
disabled={true}
testID='channel_info.options.my_autotranslation.option'
/>
);
}
const description = autotranslation ? intl.formatMessage(messages.enabled, {
language: intl.formatDisplayName(intl.locale, {type: 'language'}),
}) : intl.formatMessage(messages.disabled);
return (
<OptionItem
action={toggleAutotranslation}
label={intl.formatMessage(messages.label)}
description={description}
icon='translate'
type='toggle'
selected={autotranslation}
testID={`channel_info.options.my_autotranslation.option.toggled.${autotranslation}`}
/>
);
};
export default MyAutotranslation;

View file

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

View file

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

View file

@ -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(
<ChannelAutotranslation
channelId='channel1'
enabled={true}
displayName='Test Channel'
/>,
{database},
);
expect(getByTestId('channel_settings.channel_autotranslation.option.toggled.true')).toBeTruthy();
});
it('renders toggle with correct testID when disabled', () => {
const {getByTestId} = renderWithEverything(
<ChannelAutotranslation
channelId='channel1'
enabled={false}
displayName='Test Channel'
/>,
{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(
<ChannelAutotranslation
channelId='channel1'
enabled={false}
displayName='Test Channel'
/>,
{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(
<ChannelAutotranslation
channelId='channel1'
enabled={true}
displayName='Test Channel'
/>,
{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();
});
});
});

View file

@ -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 (
<OptionItem
action={toggleAutotranslation}
label={intl.formatMessage({
id: 'channel_settings.channel_autotranslation',
defaultMessage: 'Auto-translation',
})}
description={intl.formatMessage({
id: 'channel_settings.channel_autotranslation_description',
defaultMessage: 'When enabled, channel members can turn on auto-translation to view messages in their preferred language.',
})}
icon='translate'
type='toggle'
selected={autotranslation}
testID={`channel_settings.channel_autotranslation.option.toggled.${autotranslation}`}
/>
);
};
export default ChannelAutotranslation;

View file

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

View file

@ -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 (
<OptionItem
action={goToEditChannel}
action={goToChannelInfo}
label={title}
icon='pencil-outline'
icon='information-outline'
type={Platform.select({ios: 'arrow', default: 'default'})}
testID='channel_info.options.edit_channel.option'
testID='channel_settings.channel_info.option'
/>
);
};
export default EditChannel;
export default ChannelInfoOption;

View file

@ -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 (
<View
style={styles.flex}
nativeID={SecurityManager.getShieldScreenId(componentId)}
>
<SafeAreaView
edges={edges}
style={styles.flex}
testID='channel_settings.screen'
>
<ScrollView
bounces={true}
alwaysBounceVertical={false}
contentContainerStyle={styles.content}
testID='channel_settings.scroll_view'
>
{canManageSettings &&
<ChannelInfoOption channelId={channelId}/>
}
{canConvert &&
<ConvertPrivate
canConvert={canConvert}
channelId={channelId}
displayName={displayName}
/>
}
{canEnableDisableCalls &&
<ChannelInfoEnableCalls
channelId={channelId}
enabled={isCallsEnabledInChannel}
/>
}
{convertGMOptionAvailable &&
<ConvertToChannelLabel channelId={channelId}/>
}
{canManageSettings && isChannelAutotranslateEnabled &&
<ChannelAutotranslation channelId={channelId}/>
}
{(canArchive || canUnarchive) &&
<>
<View style={styles.separator}/>
<Archive
canArchive={canArchive}
canUnarchive={canUnarchive}
channelId={channelId}
componentId={componentId}
type={type}
/>
</>
}
</ScrollView>
</SafeAreaView>
</View>
);
};
export default ChannelSettings;

View file

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

View file

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

View file

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

View file

@ -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}
</Text>
);
if (post?.message) {
if (message) {
postBody = (
<Text numberOfLines={2}>
<RemoveMarkdown
@ -222,7 +231,7 @@ const Thread = ({author, channel, location, post, teammateNameDisplay, testID, t
enableHardBreak={true}
enableSoftBreak={true}
baseStyle={styles.message}
value={post.message.substring(0, 100)} // This substring helps to avoid ANR's
value={message.substring(0, 100)} // This substring helps to avoid ANR's
/>
</Text>
);
@ -244,6 +253,13 @@ const Thread = ({author, channel, location, post, teammateNameDisplay, testID, t
<View style={styles.header}>
<View style={styles.headerInfoContainer}>
{name}
{isChannelAutotranslated && post.type === '' && translation?.state === 'ready' && (
<CompassIcon
name='translate'
size={16}
color={changeOpacity(theme.centerChannelColor, 0.56)}
/>
)}
{threadStarterName !== channel?.displayName && (
<View style={styles.channelNameContainer}>
<TouchableWithFeedback

View file

@ -85,6 +85,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
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;

View file

@ -319,7 +319,7 @@ function Permalink({
handleJoin={handleJoin}
/>
);
} else {
} else if (channel) {
const postListContent = (
<>
<View style={style.postList}>
@ -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}

View file

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

View file

@ -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 (
<SafeAreaView

View file

@ -3,13 +3,14 @@
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {combineLatest, of as of$, Observable} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {combineLatestWith, distinctUntilChanged, switchMap} from 'rxjs/operators';
import {Permissions, Post, Screens} from '@constants';
import {AppBindingLocations} from '@constants/apps';
import {MAX_ALLOWED_REACTIONS} from '@constants/emoji';
import {DEFAULT_LOCALE} from '@i18n';
import AppsManager from '@managers/apps_manager';
import {observeChannel, observeChannelInfo, observeIsReadOnlyChannel} from '@queries/servers/channel';
import {observeChannel, observeIsReadOnlyChannel, observeIsChannelAutotranslated, observeChannelInfo} from '@queries/servers/channel';
import {observePost, observePostSaved} from '@queries/servers/post';
import {observeReactionsForPost} from '@queries/servers/reaction';
import {observePermissionForChannel, observePermissionForPost} from '@queries/servers/role';
@ -19,7 +20,7 @@ import {observeCurrentUser} from '@queries/servers/user';
import {isBoRPost, isOwnBoRPost, isUnrevealedBoRPost} from '@utils/bor';
import {toMilliseconds} from '@utils/datetime';
import {isMinimumServerVersion} from '@utils/helpers';
import {isFromWebhook, isSystemMessage} from '@utils/post';
import {getPostTranslation, isFromWebhook, isSystemMessage} from '@utils/post';
import {getPostIdsForCombinedUserActivityPost} from '@utils/post_list';
import PostOptions from './post_options';
@ -157,6 +158,15 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, sour
switchMap((enabled) => (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,

View file

@ -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<string | number> = [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 &&
<ShowTranslationOption
bottomSheetId={Screens.POST_OPTIONS}
postId={post.id}
/>
}
{canSavePost &&
<SaveOption
bottomSheetId={Screens.POST_OPTIONS}

View file

@ -0,0 +1,38 @@
// 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 {queryAllCustomEmojis} from '@queries/servers/custom_emoji';
import {observePost} from '@queries/servers/post';
import {observeConfigBooleanValue} from '@queries/servers/system';
import {observeIsCRTEnabled} from '@queries/servers/thread';
import {mapCustomEmojiNames} from '@utils/emoji/helpers';
import ShowTranslation from './show_translation';
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = WithDatabaseArgs & {
postId: string;
}
const enhance = withObservables(['postId'], ({postId, database}: Props) => {
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));

View file

@ -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<typeof ShowTranslation> {
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(<ShowTranslation {...props}/>);
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(<ShowTranslation {...props}/>);
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(<ShowTranslation {...props}/>);
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(<ShowTranslation {...props}/>);
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(<ShowTranslation {...props}/>);
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(<ShowTranslation {...props}/>);
expect(getByText('Unknown')).toBeTruthy();
});
});

View file

@ -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 (
<View style={[style.messageBlock, original && style.messageBlockOriginal]}>
<View style={style.badgeContainer}>
<Text style={style.languageText}>{language === 'unknown' ? intl.formatMessage({id: 'mobile.translation.unknown_language', defaultMessage: 'Unknown'}) : intl.formatDisplayName(language, {type: 'language'})}</Text>
<Tag
message={badgeLabel}
type={original ? 'info' : 'general'}
size='s'
/>
</View>
<Post
appsEnabled={appsEnabled}
customEmojiNames={customEmojiNames}
highlightPinnedOrSaved={false}
isCRTEnabled={isCRTEnabled}
location={Screens.SHOW_TRANSLATION}
post={post}
shouldRenderReplyButton={false}
showAddReaction={false}
isChannelAutotranslated={!original}
nextPost={undefined}
previousPost={undefined}
skipSavedHeader={true}
skipPinnedHeader={true}
/>
</View>
);
};
return (
<SafeAreaView
edges={edges}
style={style.container}
testID='show_translation.screen'
>
<ScrollView
contentContainerStyle={style.content}
showsVerticalScrollIndicator={true}
>
{renderMessageBlock(
originalLanguage,
true,
)}
{renderMessageBlock(
intl.locale,
false,
)}
</ScrollView>
</SafeAreaView>
);
}
export default ShowTranslation;

View file

@ -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<boolean | undefined>();
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 && (
<TouchableOpacity onPress={onUndoPressHandler}>
<Text style={styles.undo}>
{intl.formatMessage({
<Text style={styles.action}>
{actionText || intl.formatMessage({
id: 'snack.bar.undo',
defaultMessage: 'Undo',
})}

View file

@ -50,6 +50,22 @@ class EphemeralStoreSingleton {
// It is cleared any time the connection with the server is lost.
private channelPlaybooksSynced: {[serverUrl: string]: Set<string>} = {};
// 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<string>();
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;
};

View file

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

View file

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

View file

@ -14,6 +14,7 @@ export type ShowSnackBarArgs = {
messageValues?: Record<string, PrimitiveType>;
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,

View file

@ -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 wont 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",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more