* 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:
parent
3edc6c51ab
commit
e8f1d0c729
105 changed files with 3375 additions and 463 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: []};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ describe('Header', () => {
|
|||
teammateNameDisplay: '',
|
||||
hideGuestTags: false,
|
||||
currentUser,
|
||||
isChannelAutotranslated: false,
|
||||
};
|
||||
|
||||
it('Should show BoR icon for own BoR post', () => {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
72
app/components/post_list/post/header/translate_icon.tsx
Normal file
72
app/components/post_list/post/header/translate_icon.tsx
Normal 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;
|
||||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ describe('performance metrics', () => {
|
|||
location: 'Channel',
|
||||
post,
|
||||
isLastPost: true,
|
||||
isChannelAutotranslated: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
72
app/components/post_list/post/shimmer_animation.tsx
Normal file
72
app/components/post_list/post/shimmer_animation.tsx
Normal 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;
|
||||
76
app/components/post_list/post/use_shimmer_animation.tsx
Normal file
76
app/components/post_list/post/use_shimmer_animation.tsx
Normal 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;
|
||||
|
|
@ -79,6 +79,7 @@ describe('components/post_list/PostList', () => {
|
|||
savedPostIds: new Set(),
|
||||
testID: 'post_list',
|
||||
shouldShowJoinLeaveMessages: false,
|
||||
isChannelAutotranslated: false,
|
||||
listRef: createRef<FlatList<string | PostModel>>(),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ describe('ChannelHeader', () => {
|
|||
isOwnDirectMessage: false,
|
||||
shouldRenderChannelBanner: false,
|
||||
isPlaybooksEnabled: true,
|
||||
isChannelAutotranslated: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
@ -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));
|
||||
|
|
@ -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}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
37
app/screens/channel_info/options/channel_settings/index.tsx
Normal file
37
app/screens/channel_info/options/channel_settings/index.tsx
Normal 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;
|
||||
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}/>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
32
app/screens/channel_info/options/my_autotranslation/index.ts
Normal file
32
app/screens/channel_info/options/my_autotranslation/index.ts
Normal 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));
|
||||
|
||||
|
|
@ -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 won’t be affected.', [
|
||||
{text: 'cancel', style: 'cancel'},
|
||||
{text: 'Yes, turn off', onPress: expect.any(Function)},
|
||||
]);
|
||||
expect(setMyChannelAutotranslation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const yesButton = jest.mocked(Alert.alert).mock.calls[0][2]?.[1];
|
||||
await act(async () => {
|
||||
yesButton?.onPress?.();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(setMyChannelAutotranslation).toHaveBeenCalledWith(serverUrl, 'channel1', false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
29
app/screens/channel_settings/archive/index.ts
Normal file
29
app/screens/channel_settings/archive/index.ts
Normal 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));
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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));
|
||||
|
||||
|
|
@ -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;
|
||||
141
app/screens/channel_settings/channel_settings.tsx
Normal file
141
app/screens/channel_settings/channel_settings.tsx
Normal 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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
196
app/screens/channel_settings/index.ts
Normal file
196
app/screens/channel_settings/index.ts
Normal 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)));
|
||||
|
||||
|
|
@ -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))),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
38
app/screens/show_translation/index.ts
Normal file
38
app/screens/show_translation/index.ts
Normal 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));
|
||||
|
||||
155
app/screens/show_translation/show_translation.test.tsx
Normal file
155
app/screens/show_translation/show_translation.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
148
app/screens/show_translation/show_translation.tsx
Normal file
148
app/screens/show_translation/show_translation.tsx
Normal 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;
|
||||
|
||||
|
|
@ -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',
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -160,6 +160,7 @@
|
|||
"channel_info.channel_auto_follow_threads": "Follow all threads in this channel",
|
||||
"channel_info.channel_auto_follow_threads_failed": "An error occurred trying to auto follow all threads in channel {displayName}",
|
||||
"channel_info.channel_files": "Files",
|
||||
"channel_info.channel_settings": "Channel Settings",
|
||||
"channel_info.close": "Close",
|
||||
"channel_info.close_dm": "Close direct message",
|
||||
"channel_info.close_dm_channel": "Are you sure you want to close this direct message? This will remove it from your home screen, but you can always open it again.",
|
||||
|
|
@ -201,6 +202,11 @@
|
|||
"channel_info.mention": "Mention",
|
||||
"channel_info.mobile_notifications": "Mobile Notifications",
|
||||
"channel_info.muted": "Mute",
|
||||
"channel_info.my_autotranslation": "Auto-translation",
|
||||
"channel_info.my_autotranslation_disabled": "Off",
|
||||
"channel_info.my_autotranslation_enabled": "On ({language})",
|
||||
"channel_info.my_autotranslation_failed": "An error occurred trying to enable automatic translation for yourself in channel {displayName}",
|
||||
"channel_info.my_autotranslation_language_not_supported": "Your language is not supported",
|
||||
"channel_info.nickname": "Nickname",
|
||||
"channel_info.notification.all": "All",
|
||||
"channel_info.notification.default": "Default",
|
||||
|
|
@ -214,6 +220,10 @@
|
|||
"channel_info.send_a_mesasge": "Send a message",
|
||||
"channel_info.send_mesasge": "Send message",
|
||||
"channel_info.set_header": "Set Header",
|
||||
"channel_info.turn_off_auto_translation.button.cancel": "cancel",
|
||||
"channel_info.turn_off_auto_translation.button.yes": "Yes, turn off",
|
||||
"channel_info.turn_off_auto_translation.description": "Messages in this channel will revert to their original language. This will only affect how you see this channel. Other members won’t be affected.",
|
||||
"channel_info.turn_off_auto_translation.title": "Turn off auto-translation",
|
||||
"channel_info.unarchive": "Unarchive Channel",
|
||||
"channel_info.unarchive_description": "Are you sure you want to unarchive the {term} {name}?",
|
||||
"channel_info.unarchive_failed": "An error occurred trying to unarchive the channel {displayName}",
|
||||
|
|
@ -249,6 +259,9 @@
|
|||
"channel_notification_preferences.reset_default": "Reset to default",
|
||||
"channel_notification_preferences.thread_replies": "Thread replies",
|
||||
"channel_notification_preferences.unmute_content": "Unmute channel",
|
||||
"channel_settings.channel_autotranslation": "Auto-translation",
|
||||
"channel_settings.channel_autotranslation_description": "When enabled, channel members can turn on auto-translation to view messages in their preferred language.",
|
||||
"channel_settings.channel_autotranslation_failed": "An error occurred trying to enable automatic translation for channel {displayName}",
|
||||
"channel.abac_policy_enforced.description": "Only people who match the specified access rules can be selected and added to this channel.",
|
||||
"channel.abac_policy_enforced.title": "Channel access is restricted by user attributes",
|
||||
"channel.banner.bottom_sheet.title": "Channel Banner",
|
||||
|
|
@ -839,6 +852,7 @@
|
|||
"mobile.post_info.pin": "Pin to Channel",
|
||||
"mobile.post_info.reply": "Reply",
|
||||
"mobile.post_info.save": "Save",
|
||||
"mobile.post_info.show_translation": "Show Translation",
|
||||
"mobile.post_info.unpin": "Unpin from Channel",
|
||||
"mobile.post_info.unsave": "Unsave",
|
||||
"mobile.post_pre_header.pinned": "Pinned",
|
||||
|
|
@ -929,6 +943,9 @@
|
|||
"mobile.system_message.update_channel_purpose_message.updated_from": "{username} updated the channel purpose from: {oldPurpose} to: {newPurpose}",
|
||||
"mobile.system_message.update_channel_purpose_message.updated_to": "{username} updated the channel purpose to: {newPurpose}",
|
||||
"mobile.tos_link": "Terms of Service",
|
||||
"mobile.translation.auto_translated_badge": "AUTO-TRANSLATED",
|
||||
"mobile.translation.original_badge": "ORIGINAL",
|
||||
"mobile.translation.unknown_language": "Unknown",
|
||||
"mobile.user_list.deactivated": "Deactivated",
|
||||
"mobile.write_storage_permission_denied_description": "Save files to your device. Open Settings to grant {applicationName} write access to files on this device.",
|
||||
"modal.manual_status.auto_responder.message_": "Would you like to switch your status to \"{status}\" and disable Automatic Replies?",
|
||||
|
|
@ -1196,6 +1213,7 @@
|
|||
"post_body.check_for_out_of_channel_mentions.message.one": "was mentioned but is not in the channel. Would you like to ",
|
||||
"post_body.commentedOn": "Commented on {name}{apostrophe} message: ",
|
||||
"post_body.deleted": "(message deleted)",
|
||||
"post_header.translation_processing": "Translating...",
|
||||
"post_header.visible_message": "(Only visible to you)",
|
||||
"post_info.auto_responder": "Automatic Reply",
|
||||
"post_info.bot": "Bot",
|
||||
|
|
@ -1320,7 +1338,6 @@
|
|||
"screen.search.title": "Search",
|
||||
"screens.channel_bookmark_add": "Add a bookmark",
|
||||
"screens.channel_bookmark_edit": "Edit bookmark",
|
||||
"screens.channel_edit": "Edit Channel",
|
||||
"screens.channel_edit_header": "Edit Channel Header",
|
||||
"screens.channel_info": "Channel info",
|
||||
"screens.channel_info.dm": "Direct message info",
|
||||
|
|
@ -1448,6 +1465,7 @@
|
|||
"snack.bar.channel.members.added": "{numMembers, number} {numMembers, plural, one {member} other {members}} added",
|
||||
"snack.bar.code.copied": "Code copied to clipboard",
|
||||
"snack.bar.default": "Error",
|
||||
"snack.bar.enable.translation": "Enable auto-translation?",
|
||||
"snack.bar.favorited.channel": "This channel was favorited",
|
||||
"snack.bar.following.thread": "Thread followed",
|
||||
"snack.bar.info.copied": "Info copied to clipboard",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
130
detox/e2e/support/ui/screen/channel_settings.ts
Normal file
130
detox/e2e/support/ui/screen/channel_settings.ts
Normal 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;
|
||||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue