Compare commits

...

15 commits

Author SHA1 Message Date
unified-ci-app[bot]
937914014c
Bump app build number to 728 (#9564)
Some checks failed
github-release / test (push) Has been cancelled
github-release / build-ios-unsigned (push) Has been cancelled
github-release / build-android-unsigned (push) Has been cancelled
github-release / generate-consolidated-sbom (push) Has been cancelled
github-release / release (push) Has been cancelled
Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-02-27 17:30:57 +02:00
Mattermost Build
1c2355a91e
Update google-services.json (#9553) (#9558)
(cherry picked from commit 7afdaddd2b)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
2026-02-27 05:01:32 +02:00
Mattermost Build
98e9f91a3f
Fix Android image loads by attaching auth headers (#9554) (#9559)
* Fix images not loading on android"

* Update app/components/expo_image/index.tsx



* Update snapshots and remove accept

* Fix 16KB page size patch failing due to stale diff context (#9555)

The expo_image/index.tsx hunks in 9325-full.diff were generated before
the NetworkManager/header-handling code was added to ExpoImage. The stale
context lines caused patch's fuzzy matching to apply ExpoImage hunks to
ExpoImageBackground instead, then ExpoImageBackground hunks failed
because those sections were already modified.

Regenerated the expo_image diff hunks to match the current file state
with proper context lines for both ExpoImage (with header handling) and
ExpoImageBackground components.

https://claude.ai/code/session_01SGXWQzxrcPpWsq2YrjoUwd



---------





(cherry picked from commit 46d46790b5)

Co-authored-by: Nick Misasi <nick.misasi@mattermost.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Your Name <larkox@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-27 05:01:14 +02:00
Mattermost Build
c6caf7571f
[MM-67253] Add support for DMs and GMs for autotranslations (#9505) (#9551)
* [MM-67253] Add support for DMs and GMs for autotranslations

* Address copilot comments

(cherry picked from commit 1db5cd456c)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
2026-02-25 21:35:44 +02:00
Mattermost Build
f9d1bca05c
workflows: adjust permissions in releases (#9530) (#9549)
https://github.com/mattermost/mattermost-mobile/actions/runs/22089238110
(cherry picked from commit 36439bd5f4)

Co-authored-by: Nuno Simões <nasimoes@aiven.io>
2026-02-25 15:58:07 +02:00
unified-ci-app[bot]
85e38340ab
Bump app build number to 723 (#9535)
Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-02-19 11:02:03 +02:00
Mattermost Build
00dc0b56a7
Reduce channel banner height to 26px (#9520) (#9534)
(cherry picked from commit cef108db68)

Co-authored-by: David Krauser <david@krauser.org>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-19 08:47:18 +02:00
Mattermost Build
8f9210ec5b
[MM-67563] Change websocket format for translation update events (#9515) (#9531)
(cherry picked from commit d0cc38d549)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
2026-02-18 18:08:27 +02:00
Mattermost Build
26da2839fc
[MM-67507] Fix threads not being refetch when switching translations (#9512) (#9527)
* [MM-67507] Fix threads not being refetch when switching translations

* Fix test

(cherry picked from commit 058d080e9a)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
2026-02-18 08:57:06 +02:00
unified-ci-app[bot]
2640bf825b
Bump app build number to 722 (#9525)
Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-02-17 09:06:22 +02:00
Mattermost Build
9fe9f23af8
chore: Update NOTICE.txt file with updated dependencies (#9513) (#9514)
(cherry picked from commit dbcf2d9bd5)

Co-authored-by: unified-ci-app[bot] <121569378+unified-ci-app[bot]@users.noreply.github.com>
2026-02-17 08:22:50 +02:00
Mattermost Build
9f8f4f765a
MM-66813 - enhance sso callback metadata (#9422) (#9523)
* MM-66813 - enhance sso callback metadata

* fix translation order

* Add serverUrl validation, remove non-null assertions

(cherry picked from commit 3735aa6ff8)

Co-authored-by: Pablo Vélez <pablovv2016@gmail.com>
2026-02-17 08:21:39 +02:00
Mattermost Build
58d4d08d99
Bump app build and version number (#9501) (#9502)
* Bump app build number to 719

* Bump app version number to 2.38.0

---------


(cherry picked from commit bf3f1d5241)

Co-authored-by: unified-ci-app[bot] <121569378+unified-ci-app[bot]@users.noreply.github.com>
Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-02-11 10:32:23 +02:00
Mattermost Build
e8f1d0c729
Add autotranslations (#9324) (#9500)
* Add autotranslations

* Develop latest changes and missing features

* i18n-extract

* Fix tests and update api behavior

* Fix minor bugs

* adjustments to shimmer animation, showtranslation modal style

* Update post.tsx

* Update show_translation.tsx

* adjust sizing and opacity of translate icon

* Add channel header translated icon

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

* Move channel info to the top

* Update edit channel to channel info

* Fix align of option items

* Fix i18n

* Add detox related changes for channel settings changes

* Add tests

* Address changes around the my channel column change

* Address feedback

* Fix test

* Fix bad import

* Fix set my channel autotranslation

* Fix test

* Address feedback

* Add missing files from last commit

* Remove unneeded change

---------


(cherry picked from commit 23565b5135)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>
2026-02-11 08:09:39 +02:00
Mattermost Build
3edc6c51ab
Database changes for autotranslations (#9495) (#9499)
(cherry picked from commit 61f10310dc)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
2026-02-10 18:34:56 +02:00
147 changed files with 4335 additions and 578 deletions

View file

@ -96,6 +96,9 @@ jobs:
if: ${{ !contains(github.ref_name , 'release-ios') }}
needs:
- test
permissions:
id-token: write
contents: read
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

View file

@ -143,6 +143,8 @@ Located at `libraries/@mattermost/`:
## Testing
**Testing guide:** See [docs/testing_guide.md](docs/testing_guide.md) for how to add and structure unit tests.
### Test Organization
- **Jest coverage excludes** `/components/` and `/screens/` directories
- Mock database manager at `app/database/manager/__mocks__/index.ts`

View file

@ -20,6 +20,21 @@ A spec-compliant polyfill for Intl.DateTimeFormat fully tested by the official E
* LICENSE: MIT
---
## @formatjs/intl-displaynames
This product contains '@formatjs/intl-displaynames' by Linjie Ding.
Polyfill for: https://tc39.es/proposal-intl-displaynames
* HOMEPAGE:
* https://github.com/formatjs/formatjs
* LICENSE: MIT
---
## @formatjs/intl-getcanonicallocales

View file

@ -115,8 +115,8 @@ android {
applicationId "com.mattermost.rnbeta"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 718
versionName "2.37.0"
versionCode 728
versionName "2.38.0"
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}

View file

@ -3,14 +3,14 @@
"project_number": "184930218130",
"firebase_url": "https://api-7231322553409637977-752355.firebaseio.com",
"project_id": "api-7231322553409637977-752355",
"storage_bucket": "api-7231322553409637977-752355.appspot.com"
"storage_bucket": "api-7231322553409637977-752355.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:184930218130:android:a0e553d1a1f043b5",
"mobilesdk_app_id": "1:184930218130:android:2db4058a5b5d6506",
"android_client_info": {
"package_name": "com.mattermost.react.native"
"package_name": "com.mattermost.rn"
}
},
"oauth_client": [
@ -21,19 +21,24 @@
],
"api_key": [
{
"current_key": "AIzaSyCkZkU2ECVg-mmdCG5OoHHQXtKiENuvWPE"
"current_key": "AIzaSyASRwdI3DKpyjt_gMbL1MpViI3BTHB9rFg"
}
],
"services": {
"analytics_service": {
"status": 2
},
"appinvite_service": {
"status": 1,
"other_platform_oauth_client": []
},
"ads_service": {
"status": 2
"other_platform_oauth_client": [
{
"client_id": "109751306267633962395",
"client_type": 3
},
{
"client_id": "184930218130-1kq3fsu90eppbhh508f667og6qpqrpip.apps.googleusercontent.com",
"client_type": 2,
"ios_info": {
"bundle_id": "com.mattermost.rn"
}
}
]
}
}
},
@ -52,53 +57,27 @@
],
"api_key": [
{
"current_key": "AIzaSyCkZkU2ECVg-mmdCG5OoHHQXtKiENuvWPE"
"current_key": "AIzaSyASRwdI3DKpyjt_gMbL1MpViI3BTHB9rFg"
}
],
"services": {
"analytics_service": {
"status": 2
},
"appinvite_service": {
"status": 1,
"other_platform_oauth_client": []
},
"ads_service": {
"status": 2
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:184930218130:android:2db4058a5b5d6506",
"android_client_info": {
"package_name": "com.mattermost.rn"
}
},
"oauth_client": [
{
"client_id": "184930218130-8nahrspll1opff0uogtkh2qsv8coqngo.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyCkZkU2ECVg-mmdCG5OoHHQXtKiENuvWPE"
}
],
"services": {
"analytics_service": {
"status": 2
},
"appinvite_service": {
"status": 1,
"other_platform_oauth_client": []
},
"ads_service": {
"status": 2
"other_platform_oauth_client": [
{
"client_id": "109751306267633962395",
"client_type": 3
},
{
"client_id": "184930218130-1kq3fsu90eppbhh508f667og6qpqrpip.apps.googleusercontent.com",
"client_type": 2,
"ios_info": {
"bundle_id": "com.mattermost.rn"
}
}
]
}
}
}
],
"configuration_version": "1"
}
}

View file

@ -5,13 +5,15 @@
import {DeviceEventEmitter} from 'react-native';
import {Navigation} from '@constants';
import {ActionType, Events, Navigation} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getMyChannel} from '@queries/servers/channel';
import {getPostById} from '@queries/servers/post';
import {getCommonSystemValues, getTeamHistory} from '@queries/servers/system';
import {getTeamChannelHistory} from '@queries/servers/team';
import {dismissAllModalsAndPopToRoot, dismissAllModalsAndPopToScreen} from '@screens/navigation';
import TestHelper from '@test/test_helper';
import {
switchToChannel,
@ -27,6 +29,8 @@ import {
updateChannelsDisplayName,
showUnreadChannelsOnly,
updateDmGmDisplayName,
deletePostsForChannelsWithAutotranslation,
deletePostsForChannel,
} from './channel';
import type {ChannelModel, MyChannelModel, SystemModel} from '@database/models/server';
@ -770,19 +774,19 @@ describe('updateMyChannelFromWebsocket', () => {
const serverUrl = 'baseHandler.test.com';
const channelId = 'id1';
const teamId = 'tId1';
const channel: Channel = {
const channel: Channel = TestHelper.fakeChannel({
id: channelId,
team_id: teamId,
total_msg_count: 0,
delete_at: 0,
} as Channel;
const channelMember: ChannelMembership = {
});
const channelMember: ChannelMembership = TestHelper.fakeChannelMember({
id: 'id',
user_id: 'userid',
channel_id: channelId,
msg_count: 0,
roles: '',
} as ChannelMembership;
});
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
@ -808,6 +812,27 @@ describe('updateMyChannelFromWebsocket', () => {
expect(model).toBeDefined();
expect(model?.roles).toBe('channel_user');
});
it('calls deletePostsForChannel when autotranslation changes', async () => {
const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''});
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
await operator.handlePosts({actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, order: [post.id], posts: [post], prepareRecordsOnly: false});
await updateMyChannelFromWebsocket(serverUrl, {...channelMember, autotranslation_disabled: true}, false);
expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId, teamId: channel.team_id});
});
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, teamId: channel.team_id});
const myChannel = await getMyChannel(operator.database, channelId);
expect(myChannel?.lastFetchedAt).toBe(0);
});
it('prepareRecordsOnly true - no write and no emit', async () => {
const post = TestHelper.fakePost({id: 'postid2', channel_id: channelId, root_id: ''});
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
order: [post.id],
posts: [post],
prepareRecordsOnly: false,
});
const listener = jest.fn();
const subscription = DeviceEventEmitter.addListener(Events.POST_DELETED_FOR_CHANNEL, listener);
const {models, error} = await deletePostsForChannel(serverUrl, channelId, true);
subscription.remove();
expect(error).toBeFalsy();
expect(models.length).toBeGreaterThan(0);
expect(listener).not.toHaveBeenCalled();
const myChannel = await getMyChannel(operator.database, channelId);
expect(myChannel?._preparedState).toBe('update');
// Batch the records to avoid the invariant error
await operator.batchRecords([...models], 'test');
});
});
describe('deletePostsForChannelsWithAutotranslation', () => {
const serverUrl = 'baseHandler.test.com';
let operator: ServerDataOperator;
const channelId = 'channelid1';
const teamId = 'tId1';
const channel: Channel = TestHelper.fakeChannel({
id: channelId,
team_id: teamId,
total_msg_count: 0,
autotranslation: true,
});
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
operator = DatabaseManager.serverDatabases[serverUrl]!.operator;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
it('no myChannels with autotranslation - no deletes', async () => {
const channelMember: ChannelMembership = TestHelper.fakeChannelMember({
id: 'id',
channel_id: channelId,
msg_count: 0,
autotranslation_disabled: true,
});
const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''});
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
order: [post.id],
posts: [post],
prepareRecordsOnly: false,
});
const {models, error} = await deletePostsForChannelsWithAutotranslation(serverUrl);
expect(error).toBeUndefined();
expect(models).toEqual([]);
const databasePost = await getPostById(operator.database, post.id);
expect(databasePost).toBeDefined();
});
it('myChannels with autotranslation - delete posts', async () => {
const channelMember: ChannelMembership = TestHelper.fakeChannelMember({
id: 'id',
channel_id: channelId,
msg_count: 0,
autotranslation_disabled: false,
});
const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''});
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
order: [post.id],
posts: [post],
prepareRecordsOnly: false,
});
const {models, error} = await deletePostsForChannelsWithAutotranslation(serverUrl);
expect(error).toBeUndefined();
expect(models.length).toBeGreaterThan(0);
const databasePost = await getPostById(operator.database, post.id);
expect(databasePost).toBeUndefined();
});
it('handle error', async () => {
jest.spyOn(DatabaseManager, 'getServerDatabaseAndOperator').mockImplementationOnce(() => {
throw new Error('DB error');
});
const {models, error} = await deletePostsForChannelsWithAutotranslation(serverUrl);
expect(models).toEqual([]);
expect(error).toBeTruthy();
});
});

View file

@ -3,7 +3,7 @@
import {DeviceEventEmitter} from 'react-native';
import {General, Navigation as NavigationConstants, Preferences, Screens} from '@constants';
import {Events, General, Navigation as NavigationConstants, Preferences, Screens} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
@ -13,7 +13,9 @@ import {
prepareDeleteChannel, prepareMyChannelsForTeam, queryAllMyChannel,
getMyChannel, getChannelById, queryUsersOnChannel, queryUserChannelsByTypes,
prepareAllMyChannels,
queryMyChannelsWithAutotranslation,
} from '@queries/servers/channel';
import {prepareDeletePost, queryPostsInChannel, queryPostsInThread} from '@queries/servers/post';
import {queryDisplayNamePreferences} from '@queries/servers/preference';
import {prepareCommonSystemValues, type PrepareCommonSystemValuesArgs, getCommonSystemValues, getCurrentTeamId, setCurrentChannelId, getCurrentUserId, getConfig, getLicense} from '@queries/servers/system';
import {addChannelToTeamHistory, addTeamToTeamHistory, getTeamById, removeChannelFromTeamHistory} from '@queries/servers/team';
@ -296,9 +298,15 @@ export async function updateMyChannelFromWebsocket(serverUrl: string, channelMem
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const member = await getMyChannel(database, channelMember.channel_id);
if (member && Boolean(member.autotranslationDisabled) !== Boolean(channelMember.autotranslation_disabled)) {
await deletePostsForChannel(serverUrl, channelMember.channel_id);
}
if (member) {
member.prepareUpdate((m) => {
m.roles = channelMember.roles;
m.autotranslationDisabled = channelMember.autotranslation_disabled ?? false;
});
if (!prepareRecordsOnly) {
operator.batchRecords([member], 'updateMyChannelFromWebsocket');
@ -485,3 +493,86 @@ export const updateDmGmDisplayName = async (serverUrl: string) => {
return {error};
}
};
export async function deletePostsForChannel(serverUrl: string, channelId: string, prepareRecordsOnly = false): Promise<{models: Model[]; error?: unknown}> {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const channel = await getChannelById(database, channelId);
if (!channel) {
return {models: []};
}
const posts = await channel.posts.fetch();
if (!posts.length) {
return {models: []};
}
const preparedPostsPromises = posts.map((post) => prepareDeletePost(post));
const preparedPostsArrays = await Promise.all(preparedPostsPromises);
const preparedModels: Model[] = preparedPostsArrays.flat();
const postsInChannel = await queryPostsInChannel(database, channelId);
if (postsInChannel.length) {
for (const postRange of postsInChannel) {
const preparedPostRanges = postRange.prepareDestroyPermanently();
preparedModels.push(preparedPostRanges);
}
}
const threadPromises = posts.filter((post) => post.rootId === '').map((post) => {
return queryPostsInThread(database, post.id).fetch();
});
const threadRanges = (await Promise.all(threadPromises)).flat();
for (const threadRange of threadRanges) {
const preparedThreadRange = threadRange.prepareDestroyPermanently();
preparedModels.push(preparedThreadRange);
}
const myChannel = await getMyChannel(database, channelId);
if (myChannel) {
myChannel.prepareUpdate((v) => {
v.lastFetchedAt = 0;
});
preparedModels.push(myChannel);
}
if (preparedModels.length && !prepareRecordsOnly) {
await operator.batchRecords(preparedModels, 'deletePostsForChannel');
DeviceEventEmitter.emit(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId, teamId: channel.teamId});
}
return {error: false, models: preparedModels};
} catch (error) {
logError('Failed deletePostsForChannel', error);
return {error, models: []};
}
}
export async function deletePostsForChannelsWithAutotranslation(serverUrl: string, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const myChannels = await queryMyChannelsWithAutotranslation(database).fetch();
const deleteResults = await Promise.all(
myChannels.map((myChannel) => deletePostsForChannel(serverUrl, myChannel.id, prepareRecordsOnly)),
);
const allModels: Model[] = [];
for (const result of deleteResults) {
if (result.models) {
allModels.push(...result.models);
}
}
if (allModels.length && !prepareRecordsOnly) {
await operator.batchRecords(allModels, 'deletePostsForChannelsWithAutotranslation');
}
return {error: undefined, models: allModels};
} catch (error) {
logError('Failed deletePostsForChannelsWithAutotranslation', error);
return {error, models: []};
}
}

View file

@ -4,6 +4,7 @@
import {ActionType, Post} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getPostById} from '@queries/servers/post';
import TestHelper from '@test/test_helper';
import {COMBINED_USER_ACTIVITY} from '@utils/post_list';
@ -18,6 +19,7 @@ import {
removePostAcknowledgement,
deletePosts,
getUsersCountFromMentions,
updatePostTranslation,
} from './post';
import type ServerDataOperator from '@database/operator/server_data_operator';
@ -352,3 +354,49 @@ describe('getUsersCountFromMentions', () => {
expect(num).toBe(1);
});
});
describe('updatePostTranslation', () => {
const post = TestHelper.fakePost({id: 'postid4', channel_id: channelId});
it('post not found', async () => {
const translation: PostTranslation = {object: {message: 'Hola'}, state: 'ready', source_lang: 'en'};
const result = await updatePostTranslation(serverUrl, 'nonexistent', 'es', translation);
expect(result.error).toBe('Post not found');
});
it('updates post metadata with translation', async () => {
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
order: [post.id],
posts: [post],
prepareRecordsOnly: false,
});
const translation: PostTranslation = {object: {message: 'Hola'}, state: 'ready', source_lang: 'en'};
const result = await updatePostTranslation(serverUrl, post.id, 'es', translation);
expect(result.error).toBeUndefined();
const updatedPost = await getPostById(operator.database, post.id);
expect(updatedPost).toBeDefined();
expect(updatedPost?.metadata?.translations?.es).toEqual(translation);
});
it('handle database write error', async () => {
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
order: [post.id],
posts: [post],
prepareRecordsOnly: false,
});
const originalWrite = operator.database.write;
operator.database.write = jest.fn().mockRejectedValue(new Error('Write failed'));
const translation: PostTranslation = {object: {message: 'Hola'}, state: 'ready', source_lang: 'en'};
const result = await updatePostTranslation(serverUrl, post.id, 'es', translation);
operator.database.write = originalWrite;
expect(result.error).toBeTruthy();
});
});

View file

@ -9,6 +9,7 @@ import {countUsersFromMentions, getPostById, prepareDeletePost, queryPostsById}
import {getCurrentUserId} from '@queries/servers/system';
import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers/thread';
import {generateId} from '@utils/general';
import {safeParseJSON} from '@utils/helpers';
import {logError} from '@utils/log';
import {getLastFetchedAtFromPosts} from '@utils/post';
import {getPostIdsForCombinedUserActivityPost} from '@utils/post_list';
@ -390,3 +391,31 @@ export function getUsersCountFromMentions(serverUrl: string, mentions: string[])
return Promise.resolve(0);
}
}
export const updatePostTranslation = async (serverUrl: string, postId: string, language: string, translation: PostTranslation) => {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const post = await getPostById(database, postId);
if (!post) {
return {error: 'Post not found'};
}
await database.write(async () => {
post.update((v) => {
// At this point, the metadata is a string, so we need to parse it
let metadata: PostMetadata = safeParseJSON(v.metadata as string) as PostMetadata;
if (!metadata) {
metadata = {};
}
if (!metadata.translations) {
metadata.translations = {};
}
metadata.translations[language] = translation;
v.metadata = JSON.stringify(metadata) as any;
});
});
return {error: undefined};
} catch (error) {
logError('Failed updatePostTranslation', error);
return {error};
}
};

View file

@ -25,6 +25,7 @@ import PostModel from '@typings/database/models/servers/post';
import {isExpiredBoRPost} from '@utils/bor';
import {logError} from '@utils/log';
import {deletePostsForChannelsWithAutotranslation} from './channel';
import {deletePosts} from './post';
import type {DataRetentionPoliciesRequest} from '@actions/remote/systems';
@ -71,6 +72,9 @@ export async function storeConfig(serverUrl: string, config: ClientConfig | unde
const configsToUpdate: IdValue[] = [];
const configsToDelete: IdValue[] = [];
// Check if EnableAutoTranslation changed from enabled to disabled
const enableAutoTranslationChanged = (currentConfig?.EnableAutoTranslation === 'true') !== (config.EnableAutoTranslation === 'true');
let k: keyof ClientConfig;
for (k in config) {
if (currentConfig?.[k] !== config[k]) {
@ -92,6 +96,12 @@ export async function storeConfig(serverUrl: string, config: ClientConfig | unde
if (configsToDelete.length || configsToUpdate.length) {
const results = await operator.handleConfigs({configs: configsToUpdate, configsToDelete, prepareRecordsOnly});
DeviceEventEmitter.emit(Events.CONFIG_CHANGED, {serverUrl, config});
// If EnableAutoTranslation was disabled, delete posts and disable user autotranslation
if (enableAutoTranslationChanged) {
await deletePostsForChannelsWithAutotranslation(serverUrl, prepareRecordsOnly);
}
return results;
}
} catch (error) {

View file

@ -4,11 +4,13 @@
/* eslint-disable max-lines */
import {createIntl} from 'react-intl';
import {DeviceEventEmitter} from 'react-native';
import {DeepLink} from '@constants';
import {ActionType, DeepLink, Events} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import TestHelper from '@test/test_helper';
import {
removeMemberFromChannel,
@ -55,6 +57,8 @@ import {
handleKickFromChannel,
fetchGroupMessageMembersCommonTeams,
convertGroupMessageToPrivateChannel,
setChannelAutotranslation,
setMyChannelAutotranslation,
} from './channel';
import type ServerDataOperator from '@database/operator/server_data_operator';
@ -134,6 +138,8 @@ const mockClient = {
posts: [],
previousPostId: '',
})),
setChannelAutotranslation: jest.fn(),
setMyChannelAutotranslation: jest.fn(),
};
const teamId = 'teamid1';
@ -145,7 +151,6 @@ const intl = createIntl({
});
beforeAll(() => {
// eslint-disable-next-line
// @ts-ignore
NetworkManager.getClient = () => mockClient;
});
@ -765,3 +770,59 @@ describe('direct and group', () => {
expect(updatedChannel).toBeDefined();
});
});
describe('setChannelAutotranslation', () => {
it('should update channel and call deletePostsForChannel when disabling autotranslation', async () => {
const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''});
const channelWithAutotranslation = TestHelper.fakeChannel({id: channelId, team_id: teamId, autotranslation: true});
await operator.handleChannel({channels: [channelWithAutotranslation], prepareRecordsOnly: false});
await operator.handlePosts({actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, order: [post.id], posts: [post], prepareRecordsOnly: false});
jest.mocked(mockClient.setChannelAutotranslation).mockResolvedValue({id: channelId, autotranslation: false} as Channel);
const result = await setChannelAutotranslation(serverUrl, channelId, false);
expect(result.error).toBeUndefined();
expect(result.channel).toBeDefined();
expect(result.channel!.autotranslation).toBe(false);
expect(mockClient.setChannelAutotranslation).toHaveBeenCalledWith(channelId, false);
expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId, teamId});
});
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, teamId});
});
it('should handle client error', async () => {
jest.mocked(mockClient.setMyChannelAutotranslation).mockRejectedValueOnce(new Error('API error'));
const result = await setMyChannelAutotranslation(serverUrl, channelId, true);
expect(result.error).toBeDefined();
expect(DeviceEventEmitter.emit).not.toHaveBeenCalled();
});
});

View file

@ -5,7 +5,7 @@
import {DeviceEventEmitter} from 'react-native';
import {addChannelToDefaultCategory, handleConvertedGMCategories, storeCategories} from '@actions/local/category';
import {markChannelAsViewed, removeCurrentUserFromChannel, setChannelDeleteAt, storeAllMyChannels, storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel';
import {markChannelAsViewed, removeCurrentUserFromChannel, setChannelDeleteAt, storeAllMyChannels, storeMyChannelsForTeam, switchToChannel, deletePostsForChannel} from '@actions/local/channel';
import {switchToGlobalDrafts} from '@actions/local/draft';
import {switchToGlobalThreads} from '@actions/local/thread';
import {loadCallForChannel} from '@calls/actions/calls';
@ -272,18 +272,21 @@ export async function patchChannel(serverUrl: string, channelId: string, channel
}
const channel = await getChannelById(database, channelData.id);
if (channel && (channel.displayName !== channelData.display_name || channel.type !== channelData.type)) {
if (channel && (channel.displayName !== channelData.display_name || channel.type !== channelData.type || channel.autotranslation !== channelData.autotranslation)) {
channel.prepareUpdate((v) => {
// DM and GM display names cannot be patched and are formatted client-side; do not overwrite
if (channelData.type !== General.DM_CHANNEL && channelData.type !== General.GM_CHANNEL) {
v.displayName = channelData.display_name;
}
v.type = channelData.type;
if (channelData.autotranslation !== undefined) {
v.autotranslation = channelData.autotranslation;
}
});
models.push(channel);
}
if (models?.length) {
await operator.batchRecords(models.flat(), 'patchChannel');
await operator.batchRecords(models, 'patchChannel');
}
return {channel: channelData};
} catch (error) {
@ -1243,6 +1246,76 @@ export const updateChannelNotifyProps = async (serverUrl: string, channelId: str
}
};
export const setChannelAutotranslation = async (serverUrl: string, channelId: string, enabled: boolean) => {
try {
const client = NetworkManager.getClient(serverUrl);
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const channelData = await client.setChannelAutotranslation(channelId, enabled);
const models = [];
const channel = await getChannelById(database, channelData.id);
const autotranslationDisabled = channel && channel.autotranslation && !channelData.autotranslation;
if (channel && channel.autotranslation !== channelData.autotranslation) {
channel.prepareUpdate((v) => {
v.autotranslation = channelData.autotranslation ?? false;
});
models.push(channel);
}
if (models?.length) {
await operator.batchRecords(models, 'setChannelAutotranslation');
}
// Delete posts when autotranslation setting changes
if (autotranslationDisabled) {
await deletePostsForChannel(serverUrl, channelData.id);
}
return {channel: channelData};
} catch (error) {
logDebug('error on setChannelAutotranslation', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
export const setMyChannelAutotranslation = async (serverUrl: string, channelId: string, enabled: boolean) => {
try {
const client = NetworkManager.getClient(serverUrl);
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
await client.setMyChannelAutotranslation(channelId, enabled);
const models = [];
const myChannel = await getMyChannel(database, channelId);
const autotranslationChanged = myChannel && myChannel.autotranslationDisabled !== !enabled;
if (myChannel && autotranslationChanged) {
myChannel.prepareUpdate((v) => {
v.autotranslationDisabled = !enabled;
});
models.push(myChannel);
}
if (models?.length) {
await operator.batchRecords(models, 'setMyChannelAutotranslation');
}
// Delete posts when autotranslation setting changes
if (autotranslationChanged) {
await deletePostsForChannel(serverUrl, channelId);
}
return {data: true};
} catch (error) {
logDebug('error on setMyChannelAutotranslation', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
export const toggleMuteChannel = async (serverUrl: string, channelId: string, showSnackBar = false) => {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);

View file

@ -4,6 +4,7 @@
import {nativeApplicationVersion} from 'expo-application';
import {RESULTS, checkNotifications} from 'react-native-permissions';
import {deletePostsForChannel, deletePostsForChannelsWithAutotranslation} from '@actions/local/channel';
import {fetchChannelById, fetchMyChannelsForTeam, handleKickFromChannel, type MyChannelsRequest} from '@actions/remote/channel';
import {type MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference';
import {fetchConfigAndLicense, fetchDataRetentionPolicy} from '@actions/remote/systems';
@ -18,11 +19,12 @@ import {selectDefaultTeam} from '@helpers/api/team';
import {DEFAULT_LOCALE} from '@i18n';
import NetworkManager from '@managers/network_manager';
import {getDeviceToken} from '@queries/app/global';
import {getChannelById} from '@queries/servers/channel';
import {getChannelById, queryChannelsById, queryMyChannelsByChannelIds} from '@queries/servers/channel';
import {prepareEntryModels, truncateCrtRelatedTables} from '@queries/servers/entry';
import {getHasCRTChanged} from '@queries/servers/preference';
import {getCurrentChannelId, getCurrentTeamId, getIsDataRetentionEnabled, getPushVerificationStatus, getLastFullSync, setCurrentTeamAndChannelId, getConfigValue} from '@queries/servers/system';
import {getTeamChannelHistory} from '@queries/servers/team';
import {getCurrentUser} from '@queries/servers/user';
import NavigationStore from '@store/navigation_store';
import {isDefaultChannel, isDMorGM, sortChannelsByDisplayName} from '@utils/channel';
import {getFullErrorMessage} from '@utils/errors';
@ -179,6 +181,8 @@ const entryRest = async (serverUrl: string, teamId?: string, channelId?: string,
const dt = Date.now();
await handleAutotranslationChanges(serverUrl, meData, chData);
const modelPromises = await prepareEntryModels({operator, teamData: initialTeamData, chData, prefData, meData, isCRTEnabled});
const models = (await Promise.all(modelPromises)).flat();
logDebug('Process models on entry', groupLabel, models.length, `${Date.now() - dt}ms`);
@ -190,6 +194,65 @@ const entryRest = async (serverUrl: string, teamId?: string, channelId?: string,
}
};
export async function handleAutotranslationChanges(serverUrl: string, meData: MyUserRequest | undefined, chData: MyChannelsRequest | undefined) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
// Config changes already handled in storeConfigAndLicense
const enableAutoTranslation = (await getConfigValue(operator.database, 'EnableAutoTranslation')) === 'true';
if (!enableAutoTranslation) {
return;
}
// If user locale changed, delete all the posts for all channels with autotranslation enabled
// so the posts can be refetch with the new translation
if (meData) {
const currentUser = await getCurrentUser(database);
if (currentUser && meData.user?.locale !== currentUser.locale) {
await deletePostsForChannelsWithAutotranslation(serverUrl);
return;
}
}
if (chData) {
// If a channel stop being autotranslated by the admin, delete the posts for that channel
// so the posts can be refetch without the translation
const newChannels = chData.channels || [];
const channels = await queryChannelsById(database, newChannels.map((c) => c.id)).fetch();
const chMap = new Map(channels.map((c) => [c.id, c]));
const promises = [];
for (const ch of newChannels) {
const channel = chMap.get(ch.id);
if (channel && channel.autotranslation && !ch.autotranslation) { // Autotranslation disabled
promises.push(deletePostsForChannel(serverUrl, ch.id, true));
}
}
const chModels = (await Promise.all(promises)).map((m) => m.models).flat();
if (chModels.length) {
await operator.batchRecords(chModels, 'handleAutotranslationChanges');
}
// If a channel starts or stops being autotranslated by the user, delete the posts for that channel
// so the posts can be refetch with or without the translation
const newMemberships = chData.memberships || [];
const memberships = await queryMyChannelsByChannelIds(database, newMemberships.map((m) => m.channel_id)).fetch();
const membershipMap = new Map(memberships.map((m) => [m.id, m]));
for (const m of newMemberships) {
const membership = membershipMap.get(m.channel_id);
if (membership && membership.autotranslationDisabled !== Boolean(m.autotranslation_disabled)) { // Autotranslation modified
promises.push(deletePostsForChannel(serverUrl, m.channel_id, true));
}
}
const membershipModels = (await Promise.all(promises)).map((m) => m.models).flat();
if (membershipModels.length) {
await operator.batchRecords(membershipModels, 'handleAutotranslationChanges');
}
}
} catch (error) {
logError('handleAutotranslationChanges', getFullErrorMessage(error));
}
}
export async function entryInitialChannelId(database: Database, requestedChannelId = '', requestedTeamId = '', initialTeamId: string, locale: string, channels?: Channel[], memberships?: ChannelMember[]) {
const membershipIds = new Set(memberships?.map((m) => m.channel_id));
const requestedChannel = channels?.find((c) => (c.id === requestedChannelId) && membershipIds.has(c.id));

View file

@ -335,7 +335,7 @@ describe('actions/remote/entry/deferred', () => {
);
});
expect(processEntryModels).toHaveBeenCalledWith(
expect(processEntryModels).toHaveBeenCalledWith(serverUrl,
expect.objectContaining({
teamData: expect.objectContaining({
teams: expect.arrayContaining([
@ -345,7 +345,7 @@ describe('actions/remote/entry/deferred', () => {
}),
);
expect(processEntryModels).toHaveBeenCalledWith(
expect(processEntryModels).toHaveBeenCalledWith(serverUrl,
expect.objectContaining({
teamData: expect.objectContaining({
teams: expect.arrayContaining([

View file

@ -102,7 +102,7 @@ export async function restDeferredAppEntryActions(
};
/* eslint-disable-next-line no-await-in-loop */
await processEntryModels({operator, teamData: currentTeamData, chData: data, isCRTEnabled});
await processEntryModels(serverUrl, {operator, teamData: currentTeamData, chData: data, isCRTEnabled});
}
const uniqueChannelsData: MyChannelsRequest = {

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-lines */
import {waitFor} from '@testing-library/react-native';
import {ActionType} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
@ -20,8 +20,10 @@ import {
loadEarlierThreads,
fetchAndSwitchToThread,
syncThreadsIfNeeded,
batchTeamThreadSync,
} from './thread';
import type {Client} from '@client/rest';
import type ServerDataOperator from '@database/operator/server_data_operator';
const serverUrl = 'baseHandler.test.com';
@ -62,14 +64,15 @@ const throwFunc = () => {
throw Error('error');
};
const mockClient = {
getThread: jest.fn((userId: string, _teamId: string, threadId: string) => ({...thread1, id: threadId})),
const partialClient: Partial<Client> = {
getThread: jest.fn((userId: string, _teamId: string, threadId: string) => Promise.resolve({...thread1, id: threadId})),
updateTeamThreadsAsRead: jest.fn(),
markThreadAsRead: jest.fn(),
markThreadAsUnread: jest.fn(),
updateThreadFollow: jest.fn(),
getThreads: jest.fn(() => ({threads})),
getThreads: jest.fn(() => Promise.resolve({threads, total: threads.length, total_unread_mentions: 0, total_unread_threads: 0})),
};
const mockClient = partialClient as Client;
let mockGetIsCRTEnabled: jest.Mock;
jest.mock('@queries/servers/thread', () => {
@ -82,7 +85,7 @@ jest.mock('@queries/servers/thread', () => {
});
beforeAll(() => {
// eslint-disable-next-line
// @ts-ignore
NetworkManager.getClient = () => mockClient;
});
@ -98,7 +101,7 @@ afterEach(async () => {
describe('get threads', () => {
it('fetchThread - handle error', async () => {
mockClient.getThread.mockImplementationOnce(jest.fn(throwFunc));
jest.mocked(mockClient.getThread).mockImplementationOnce(jest.fn(throwFunc));
const result = await fetchThread(serverUrl, teamId, thread1.id);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
@ -229,7 +232,7 @@ describe('get threads', () => {
describe('update threads', () => {
it('updateTeamThreadsAsRead - handle error', async () => {
mockClient.updateTeamThreadsAsRead.mockImplementationOnce(jest.fn(throwFunc));
jest.mocked(mockClient.updateTeamThreadsAsRead).mockImplementationOnce(jest.fn(throwFunc));
const result = await updateTeamThreadsAsRead(serverUrl, teamId);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
@ -302,3 +305,109 @@ describe('update threads', () => {
expect(result.error).toBeUndefined();
});
});
describe('batchTeamThreadSync', () => {
// We don't use fake timers since they break the
// database operations.
beforeEach(() => {
jest.mocked(mockClient.getThreads).mockClear();
});
const teamFilter = (id: string) => {
return (call: unknown[]) => call[1] === id && call[6] === undefined;
};
// fetchThreads calls twice the function, one for the unreads and one for
// the latests. We expect that part to work correctly and if one is called
// for unreads, the one for the latest is also called.
const allCallsFilter = (call: unknown[]) => call[6] === undefined;
it('returns early when CRT is disabled', async () => {
mockGetIsCRTEnabled.mockResolvedValueOnce(false);
await batchTeamThreadSync(serverUrl, teamId);
await waitFor(() => expect(mockClient.getThreads).not.toHaveBeenCalled(), {timeout: 600});
});
it('schedules sync after timeout when CRT is enabled', async () => {
mockGetIsCRTEnabled.mockResolvedValue(true);
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
await operator.handleUsers({users: [user1], prepareRecordsOnly: false});
await batchTeamThreadSync(serverUrl, teamId);
expect(mockClient.getThreads).not.toHaveBeenCalled();
await waitFor(() => expect(mockClient.getThreads).toHaveBeenCalled());
const getThreadsCalls = jest.mocked(mockClient.getThreads).mock.calls;
expect(getThreadsCalls.filter(teamFilter(teamId))).toHaveLength(1);
expect(getThreadsCalls.filter(allCallsFilter)).toHaveLength(1);
});
it('batches multiple teamIds and syncs each after timeout', async () => {
mockGetIsCRTEnabled.mockResolvedValue(true);
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
await operator.handleUsers({users: [user1], prepareRecordsOnly: false});
await batchTeamThreadSync(serverUrl, teamId);
await batchTeamThreadSync(serverUrl, 'teamid2');
expect(mockClient.getThreads).not.toHaveBeenCalled();
await waitFor(() => expect(mockClient.getThreads).toHaveBeenCalled());
const getThreadsCalls = jest.mocked(mockClient.getThreads).mock.calls;
expect(getThreadsCalls.filter(teamFilter(teamId))).toHaveLength(1);
expect(getThreadsCalls.filter(teamFilter('teamid2'))).toHaveLength(1);
expect(getThreadsCalls.filter(allCallsFilter)).toHaveLength(2);
});
it('calling several times for the same team id does not create multiple calls', async () => {
mockGetIsCRTEnabled.mockResolvedValue(true);
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
await operator.handleUsers({users: [user1], prepareRecordsOnly: false});
await batchTeamThreadSync(serverUrl, teamId);
await batchTeamThreadSync(serverUrl, teamId);
await waitFor(() => expect(mockClient.getThreads).toHaveBeenCalled());
const getThreadsCalls = jest.mocked(mockClient.getThreads).mock.calls;
expect(getThreadsCalls.filter(teamFilter(teamId))).toHaveLength(1);
expect(getThreadsCalls.filter(allCallsFilter)).toHaveLength(1);
});
it('includes direct channels when empty teamId is in batch only once', async () => {
mockGetIsCRTEnabled.mockResolvedValue(true);
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
await operator.handleUsers({users: [user1], prepareRecordsOnly: false});
await batchTeamThreadSync(serverUrl, '');
await batchTeamThreadSync(serverUrl, teamId);
await batchTeamThreadSync(serverUrl, 'teamid2');
await batchTeamThreadSync(serverUrl, 'teamid3');
await waitFor(() => expect(mockClient.getThreads).toHaveBeenCalled());
expect(mockClient.getThreads).toHaveBeenCalled();
const getThreadsCalls = jest.mocked(mockClient.getThreads).mock.calls;
expect(getThreadsCalls.filter(teamFilter(teamId)).length).toBe(1);
expect(getThreadsCalls.filter(teamFilter('teamid2')).length).toBe(1);
expect(getThreadsCalls.filter(teamFilter('teamid3')).length).toBe(1);
expect(getThreadsCalls.filter(allCallsFilter)).toHaveLength(3);
expect(getThreadsCalls.filter((call) => call[10] === false && call[6] === undefined).length).toBe(1);
});
it('logs error when syncTeamThreads returns error', async () => {
mockGetIsCRTEnabled.mockResolvedValue(true);
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
await operator.handleUsers({users: [user1], prepareRecordsOnly: false});
const logErrorSpy = jest.spyOn(require('@utils/log'), 'logError').mockImplementation(() => {});
jest.mocked(mockClient.getThreads).mockImplementationOnce(() => {
throw new Error('sync failed');
});
await batchTeamThreadSync(serverUrl, teamId);
await waitFor(() => expect(logErrorSpy).toHaveBeenCalled());
expect(logErrorSpy).toHaveBeenCalledWith('batchTeamThreadSync: Error', expect.any(Error));
logErrorSpy.mockRestore();
});
});

View file

@ -530,3 +530,53 @@ export const loadEarlierThreads = async (serverUrl: string, teamId: string, last
return {error};
}
};
const handlePostDeletedForChannelIfNeededBatch: Record<string, {
timeout: NodeJS.Timeout | undefined;
teamIds: Set<string>;
}> = {};
const handlePostDeletedForChannelIfNeededBatchTimeout = 500;
export const batchTeamThreadSync = async (serverUrl: string, teamId: string) => {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const isCRTEnabled = await getIsCRTEnabled(database);
if (!isCRTEnabled) {
return;
}
let existingBatch = handlePostDeletedForChannelIfNeededBatch[serverUrl];
if (!existingBatch) {
existingBatch = {
timeout: undefined,
teamIds: new Set(),
};
handlePostDeletedForChannelIfNeededBatch[serverUrl] = existingBatch;
}
clearTimeout(existingBatch.timeout);
existingBatch.teamIds.add(teamId);
existingBatch.timeout = setTimeout(async () => {
const batch = handlePostDeletedForChannelIfNeededBatch[serverUrl];
if (!batch) {
return;
}
delete handlePostDeletedForChannelIfNeededBatch[serverUrl];
const includeDirect = batch.teamIds.has('');
const teamIds = Array.from(batch.teamIds).filter((t) => t !== '');
const promises = teamIds.map((t, idx) => syncTeamThreads(serverUrl, t, {excludeDirect: !includeDirect || idx !== 0, refresh: true, fetchOnly: false}));
const results = await Promise.all(promises);
for (const r of results) {
if (r.error) {
logError('batchTeamThreadSync: Error', r.error);
}
}
}, handlePostDeletedForChannelIfNeededBatchTimeout);
} catch (error) {
logError('handlePostDeletedForChannelIfNeeded: Error', error);
}
};

View file

@ -5,7 +5,7 @@
import {chunk} from 'lodash';
import {updateChannelsDisplayName} from '@actions/local/channel';
import {updateChannelsDisplayName, deletePostsForChannelsWithAutotranslation} from '@actions/local/channel';
import {updateRecentCustomStatuses, updateLocalUser} from '@actions/local/user';
import {fetchRolesIfNeeded} from '@actions/remote/role';
import {General} from '@constants';
@ -15,7 +15,7 @@ import NetworkManager from '@managers/network_manager';
import {getMembersCountByChannelsId, queryChannelsByTypes} from '@queries/servers/channel';
import {queryGroupsByNames} from '@queries/servers/group';
import {getCurrentUserId, setCurrentUserId} from '@queries/servers/system';
import {getCurrentUser, prepareUsers, queryAllUsers, queryUsersById, queryUsersByIdsOrUsernames, queryUsersByUsername} from '@queries/servers/user';
import {getCurrentUser, getUserById, prepareUsers, queryAllUsers, queryUsersById, queryUsersByIdsOrUsernames, queryUsersByUsername} from '@queries/servers/user';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';
import {getDeviceTimezone} from '@utils/timezone';
@ -47,7 +47,7 @@ export type ProfilesInChannelRequest = {
export const fetchMe = async (serverUrl: string, fetchOnly = false, groupLabel?: RequestGroupLabel): Promise<MyUserRequest> => {
try {
const client = NetworkManager.getClient(serverUrl);
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const resultSettled = await Promise.allSettled([client.getMe(groupLabel), client.getStatus('me', groupLabel)]);
let user: UserProfile|undefined;
@ -71,6 +71,14 @@ export const fetchMe = async (serverUrl: string, fetchOnly = false, groupLabel?:
if (!fetchOnly) {
await operator.handleUsers({users: [user], prepareRecordsOnly: false});
const currentUser = await getUserById(database, user.id);
if (currentUser) {
const localeChanged = currentUser.locale !== user.locale;
if (localeChanged) {
await deletePostsForChannelsWithAutotranslation(serverUrl);
}
}
}
return {user};

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import {addChannelToDefaultCategory, handleConvertedGMCategories} from '@actions/local/category';
import {markChannelAsViewed, removeCurrentUserFromChannel, setChannelDeleteAt, storeMyChannelsForTeam, updateChannelInfoFromChannel, updateMyChannelFromWebsocket} from '@actions/local/channel';
import {markChannelAsViewed, removeCurrentUserFromChannel, setChannelDeleteAt, storeMyChannelsForTeam, updateChannelInfoFromChannel, updateMyChannelFromWebsocket, deletePostsForChannel} from '@actions/local/channel';
import {storePostsForChannel} from '@actions/local/post';
import {fetchMyChannel, fetchChannelById, fetchMissingDirectChannelsInfo} from '@actions/remote/channel';
import {fetchPostsForChannel} from '@actions/remote/post';
@ -255,6 +255,31 @@ describe('WebSocket Channel Actions', () => {
expect(setCurrentTeamId).not.toHaveBeenCalled();
});
it('should call deletePostsForChannel when autotranslation is disabled', async () => {
jest.mocked(EphemeralStore.isConvertingChannel).mockReturnValue(false);
mockedGetChannelById.mockResolvedValue({...channelModel, autotranslation: true} as ChannelModel);
msg.data.channel = JSON.stringify({id: channelId, type: General.PRIVATE_CHANNEL, team_id: teamId, autotranslation: false});
jest.mocked(updateChannelInfoFromChannel).mockResolvedValue({model: []});
jest.spyOn(operator, 'handleChannel').mockResolvedValueOnce([]);
await handleChannelUpdatedEvent(serverUrl, msg);
expect(deletePostsForChannel).toHaveBeenCalledWith(serverUrl, channelId);
});
it('should not call deletePostsForChannel when autotranslation stays enabled', async () => {
jest.mocked(EphemeralStore.isConvertingChannel).mockReturnValue(false);
mockedGetChannelById.mockResolvedValue({...channelModel, autotranslation: true} as ChannelModel);
msg.data.channel = JSON.stringify({id: channelId, type: General.PRIVATE_CHANNEL, team_id: teamId, autotranslation: true});
jest.mocked(updateChannelInfoFromChannel).mockResolvedValue({model: []});
jest.spyOn(operator, 'handleChannel').mockResolvedValueOnce([]);
(deletePostsForChannel as jest.Mock).mockClear();
await handleChannelUpdatedEvent(serverUrl, msg);
expect(deletePostsForChannel).not.toHaveBeenCalled();
});
});
describe('handleChannelViewedEvent', () => {

View file

@ -4,7 +4,7 @@
import {addChannelToDefaultCategory, handleConvertedGMCategories} from '@actions/local/category';
import {
markChannelAsViewed, removeCurrentUserFromChannel, setChannelDeleteAt,
storeMyChannelsForTeam, updateChannelInfoFromChannel, updateMyChannelFromWebsocket,
storeMyChannelsForTeam, updateChannelInfoFromChannel, updateMyChannelFromWebsocket, deletePostsForChannel,
} from '@actions/local/channel';
import {storePostsForChannel} from '@actions/local/post';
import {fetchMissingDirectChannelsInfo, fetchMyChannel, fetchChannelStats, fetchChannelById, handleKickFromChannel} from '@actions/remote/channel';
@ -105,6 +105,11 @@ export async function handleChannelUpdatedEvent(serverUrl: string, msg: any) {
const existingChannel = await getChannelById(database, updatedChannel.id);
const existingChannelType = existingChannel?.type;
const autotranslationDisabled = existingChannel?.autotranslation && !updatedChannel.autotranslation;
if (autotranslationDisabled) {
await deletePostsForChannel(serverUrl, updatedChannel.id);
}
const models: Model[] = await operator.handleChannel({channels: [updatedChannel], prepareRecordsOnly: true});
const infoModel = await updateChannelInfoFromChannel(serverUrl, updatedChannel, true);
if (infoModel.model) {

View file

@ -19,6 +19,7 @@ import * as channel from './channel';
import * as group from './group';
import {handleOpenDialogEvent} from './integrations';
import * as posts from './posts';
import {handlePostTranslationUpdatedEvent} from './posts';
import * as preferences from './preferences';
import {handleAddCustomEmoji, handleReactionRemovedFromPostEvent, handleReactionAddedToPostEvent} from './reactions';
import {handleUserRoleUpdatedEvent, handleTeamMemberRoleUpdatedEvent, handleRoleUpdatedEvent} from './roles';
@ -324,6 +325,11 @@ export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMess
case WebsocketEvents.BURN_ON_READ_ALL_REVEALED:
handleBoRPostAllRevealed(serverUrl, msg);
break;
// Autotranslation
case WebsocketEvents.POST_TRANSLATION_UPDATED:
handlePostTranslationUpdatedEvent(serverUrl, msg);
break;
}
handlePlaybookEvents(serverUrl, msg);
}

View file

@ -6,8 +6,9 @@ import {isAgentPost} from '@agents/utils';
import {DeviceEventEmitter} from 'react-native';
import {storeMyChannelsForTeam, markChannelAsUnread, markChannelAsViewed, updateLastPostAt} from '@actions/local/channel';
import {addPostAcknowledgement, markPostAsDeleted, removePostAcknowledgement} from '@actions/local/post';
import {addPostAcknowledgement, markPostAsDeleted, removePostAcknowledgement, updatePostTranslation} from '@actions/local/post';
import {createThreadFromNewPost, updateThread} from '@actions/local/thread';
import {getCurrentUserLocale} from '@actions/local/user';
import {fetchChannelStats, fetchMyChannel} from '@actions/remote/channel';
import {fetchPostAuthors, fetchPostById} from '@actions/remote/post';
import {openChannelIfNeeded} from '@actions/remote/preference';
@ -404,3 +405,24 @@ export async function handlePostAcknowledgementRemoved(serverUrl: string, msg: W
// Do nothing
}
}
export async function handlePostTranslationUpdatedEvent(serverUrl: string, msg: WebSocketMessage) {
try {
const translationData: PostTranslationUpdateData = msg.data;
const locale = await getCurrentUserLocale(serverUrl);
const myLanguageTranslation = translationData.translations[locale];
if (!myLanguageTranslation) {
return;
}
const translationObject: PostTranslation['object'] = myLanguageTranslation?.translation ? JSON.parse(myLanguageTranslation.translation) : undefined;
await updatePostTranslation(serverUrl, translationData.object_id, locale, {
object: translationObject,
state: myLanguageTranslation.state,
source_lang: myLanguageTranslation.src_lang,
});
} catch (error) {
// Do nothing
}
}

View file

@ -3,7 +3,7 @@
import {DeviceEventEmitter} from 'react-native';
import {updateChannelsDisplayName} from '@actions/local/channel';
import {deletePostsForChannelsWithAutotranslation, updateChannelsDisplayName} from '@actions/local/channel';
import {setCurrentUserStatus} from '@actions/local/user';
import {fetchMe, fetchUsersByIds} from '@actions/remote/user';
import {General, Events, Preferences} from '@constants';
@ -59,6 +59,9 @@ export async function handleUserUpdatedEvent(serverUrl: string, msg: WebSocketMe
modelsToBatch.push(...models);
}
}
// Delete posts for all channels with user autotranslation enabled when locale changes
await deletePostsForChannelsWithAutotranslation(serverUrl);
}
}
} else {

View file

@ -137,6 +137,33 @@ describe('ClientChannels', () => {
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('setChannelAutotranslation', async () => {
const channelId = 'channel1';
const enabled = true;
const expectedUrl = `${client.getChannelRoute(channelId)}/patch`;
const expectedOptions = {method: 'put', body: {autotranslation: enabled}};
jest.mocked(client.doFetch).mockResolvedValue({id: channelId, autotranslation: enabled});
const result = await client.setChannelAutotranslation(channelId, enabled);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
expect(result.autotranslation).toBe(enabled);
});
test('setMyChannelAutotranslation', async () => {
const channelId = 'channel1';
const enabled = false;
const expectedUrl = `${client.getChannelMemberRoute(channelId, 'me')}/autotranslation`;
const expectedOptions = {method: 'put', body: {autotranslation_disabled: !enabled}};
const mockMembership = {id: 'me-channel1', channel_id: channelId, user_id: 'me', autotranslation_disabled: !enabled};
jest.mocked(client.doFetch).mockResolvedValue(mockMembership);
const result = await client.setMyChannelAutotranslation(channelId, enabled);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
expect(result).toEqual(mockMembership);
});
test('getChannel', async () => {
const channelId = 'channel1';
const expectedUrl = client.getChannelRoute(channelId);

View file

@ -19,6 +19,8 @@ export interface ClientChannelsMix {
updateChannelPrivacy: (channelId: string, privacy: any) => Promise<Channel>;
patchChannel: (channelId: string, channelPatch: Partial<Channel>) => Promise<Channel>;
updateChannelNotifyProps: (props: ChannelNotifyProps & {channel_id: string; user_id: string}) => Promise<any>;
setChannelAutotranslation: (channelId: string, enabled: boolean) => Promise<Channel>;
setMyChannelAutotranslation: (channelId: string, enabled: boolean) => Promise<ChannelMembership>;
getChannel: (channelId: string, groupLabel?: RequestGroupLabel) => Promise<Channel>;
getChannelByName: (teamId: string, channelName: string, includeDeleted?: boolean) => Promise<Channel>;
getChannelByNameAndTeamName: (teamName: string, channelName: string, includeDeleted?: boolean) => Promise<Channel>;
@ -157,6 +159,17 @@ const ClientChannels = <TBase extends Constructor<ClientBase>>(superclass: TBase
);
};
setChannelAutotranslation = async (channelId: string, enabled: boolean) => {
return this.patchChannel(channelId, {autotranslation: enabled});
};
setMyChannelAutotranslation = async (channelId: string, enabled: boolean) => {
return this.doFetch(
`${this.getChannelMemberRoute(channelId, 'me')}/autotranslation`,
{method: 'put', body: {autotranslation_disabled: !enabled}},
);
};
getChannel = async (channelId: string, groupLabel?: RequestGroupLabel) => {
return this.doFetch(
this.getChannelRoute(channelId),

View file

@ -6,3 +6,4 @@ export {default as CopyPermalinkOption} from './copy_permalink_option';
export {default as FollowThreadOption} from './follow_thread_option';
export {default as ReplyOption} from './reply_option';
export {default as SaveOption} from './save_option';
export {default as ShowTranslationOption} from './show_translation_option';

View file

@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {defineMessages, useIntl} from 'react-intl';
import {BaseOption} from '@components/common_post_options';
import {Screens} from '@constants';
import {dismissBottomSheet, goToScreen} from '@screens/navigation';
import type {AvailableScreens} from '@typings/screens/navigation';
type Props = {
bottomSheetId: AvailableScreens;
postId: string;
}
const messages = defineMessages({
showTranslation: {
id: 'mobile.post_info.show_translation',
defaultMessage: 'Show Translation',
},
});
const ShowTranslationOption = ({bottomSheetId, postId}: Props) => {
const intl = useIntl();
const onHandlePress = useCallback(async () => {
await dismissBottomSheet(bottomSheetId);
goToScreen(Screens.SHOW_TRANSLATION, intl.formatMessage(messages.showTranslation), {postId});
}, [bottomSheetId, intl, postId]);
return (
<BaseOption
message={messages.showTranslation}
iconName='translate'
onPress={onHandlePress}
testID='post_options.show_translation.option'
/>
);
};
export default ShowTranslationOption;

View file

@ -6,6 +6,7 @@ import React, {forwardRef, useMemo} from 'react';
import Animated from 'react-native-reanimated';
import {useServerUrl} from '@context/server';
import NetworkManager from '@managers/network_manager';
import {urlSafeBase64Encode} from '@utils/security';
type ExpoImagePropsWithId = ImageProps & {id: string};
@ -16,8 +17,36 @@ type ExpoImageBackgroundPropsWithId = ImageBackgroundProps & {id: string};
type ExpoImageBackgroundPropsMemoryOnly = ImageBackgroundProps & {cachePolicy: 'memory'; id?: string};
type ExpoImageBackgroundProps = ExpoImageBackgroundPropsWithId | ExpoImageBackgroundPropsMemoryOnly;
function shouldAttachServerAuthHeaders(uri: string | undefined, serverUrl: string) {
if (!uri) {
return false;
}
try {
const requestUrl = new URL(uri);
const serverBaseUrl = new URL(serverUrl);
if (requestUrl.origin !== serverBaseUrl.origin) {
return false;
}
return requestUrl.pathname.startsWith('/api/v4/');
} catch {
// On any parsing error, do not attach auth headers
return false;
}
}
const ExpoImage = forwardRef<Image, ExpoImageProps>(({id, ...props}, ref) => {
const serverUrl = useServerUrl();
const requestHeaders = useMemo(() => {
try {
const client = NetworkManager.getClient(serverUrl);
return client.getRequestHeaders('GET');
} catch {
return undefined;
}
}, [serverUrl]);
/**
* SECURITY NOTE: cachePath uses base64 encoding for URL safety, NOT encryption.
@ -30,17 +59,24 @@ const ExpoImage = forwardRef<Image, ExpoImageProps>(({id, ...props}, ref) => {
return props.source;
}
const sourceHeaders = shouldAttachServerAuthHeaders(props.source?.uri, serverUrl) && requestHeaders ? {...requestHeaders, ...props.source?.headers} : props.source?.headers;
delete sourceHeaders?.Accept;
// Only add cacheKey and cachePath if id is provided (i.e., not memory-only caching)
if (id) {
return {
...props.source,
headers: sourceHeaders,
cacheKey: id,
cachePath,
};
}
return props.source;
}, [id, props.source, cachePath]);
return {
...props.source,
headers: sourceHeaders,
};
}, [id, props.source, cachePath, requestHeaders, serverUrl]);
// Process placeholder to add cachePath and cacheKey if it has a uri
const placeholder: ImageSource | undefined = useMemo(() => {
@ -48,17 +84,24 @@ const ExpoImage = forwardRef<Image, ExpoImageProps>(({id, ...props}, ref) => {
return props.placeholder;
}
const placeholderHeaders = shouldAttachServerAuthHeaders(props.placeholder?.uri, serverUrl) && requestHeaders ? {...requestHeaders, ...props.placeholder?.headers} : props.placeholder?.headers;
delete placeholderHeaders?.Accept;
// If placeholder has a uri and id is provided, add cachePath and cacheKey
if (props.placeholder.uri && id) {
return {
...props.placeholder,
headers: placeholderHeaders,
cacheKey: `${id}-thumb`,
cachePath,
};
}
return props.placeholder;
}, [props.placeholder, id, cachePath]);
return {
...props.placeholder,
headers: placeholderHeaders,
};
}, [props.placeholder, id, cachePath, requestHeaders, serverUrl]);
return (
<Image

View file

@ -38,6 +38,7 @@ type Props = {
subtitleCompanion?: React.ReactElement;
theme: Theme;
title?: string;
titleCompanion?: React.ReactElement;
}
const hitSlop = {top: 20, bottom: 20, left: 20, right: 20};
@ -126,6 +127,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
color: theme.sidebarHeaderTextColor,
...typography('Heading', 300),
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
}));
const Header = ({
@ -143,6 +149,7 @@ const Header = ({
subtitleCompanion,
theme,
title,
titleCompanion,
}: Props) => {
const styles = getStyleSheet(theme);
const insets = useSafeAreaInsets();
@ -216,14 +223,17 @@ const Header = ({
>
<View style={styles.centered}>
{!hasSearch &&
<Animated.Text
ellipsizeMode='tail'
numberOfLines={1}
style={[styles.title, opacity]}
testID='navigation.header.title'
>
{title}
</Animated.Text>
<View style={styles.titleRow}>
<Animated.Text
ellipsizeMode='tail'
numberOfLines={1}
style={[styles.title, opacity]}
testID='navigation.header.title'
>
{title}
</Animated.Text>
{titleCompanion}
</View>
}
{!isLargeTitle && Boolean(subtitle || subtitleCompanion) &&
<View style={styles.subtitleContainer}>

View file

@ -30,6 +30,7 @@ type Props = SearchProps & {
subtitle?: string;
subtitleCompanion?: React.ReactElement;
title?: string;
titleCompanion?: React.ReactElement;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
@ -54,6 +55,7 @@ const NavigationHeader = forwardRef<SearchRef, Props>(({
subtitle,
subtitleCompanion,
title = '',
titleCompanion,
hideHeader,
...searchProps
}: Props, ref) => {
@ -101,6 +103,7 @@ const NavigationHeader = forwardRef<SearchRef, Props>(({
subtitleCompanion={subtitleCompanion}
theme={theme}
title={title}
titleCompanion={titleCompanion}
/>
{isLargeTitle &&
<NavigationHeaderLargeTitle

View file

@ -57,6 +57,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
minHeight: ITEM_HEIGHT,
gap: 12,
justifyContent: 'space-between',
paddingVertical: 12,
},
disabled: {
opacity: 0.6,
},
destructive: {
color: theme.dndIndicator,
@ -114,6 +118,7 @@ export type OptionItemProps = {
action?: (React.Dispatch<React.SetStateAction<string | boolean>>)|((value: string | boolean) => void);
description?: string;
destructive?: boolean;
disabled?: boolean;
icon?: string;
iconColor?: string;
info?: string | UserChipData;
@ -136,6 +141,7 @@ const OptionItem = ({
action,
description,
destructive,
disabled = false,
icon,
iconColor,
info,
@ -161,8 +167,9 @@ const OptionItem = ({
const labelContainerStyle = useMemo(() => {
const extraStyle = longInfo ? {flex: undefined} : {};
return [styles.labelContainer, extraStyle];
}, [longInfo, styles.labelContainer]);
const alignmentStyle = description ? {alignItems: 'flex-start' as const} : {};
return [styles.labelContainer, extraStyle, alignmentStyle];
}, [longInfo, styles.labelContainer, description]);
const labelStyle = useMemo(() => {
return isInLine ? styles.inlineLabel : styles.label;
@ -194,6 +201,10 @@ const OptionItem = ({
return [styles.actionContainer, extraStyle];
}, [longInfo, styles.actionContainer, styles.shrink]);
const containerStyle = useMemo(() => {
return disabled ? [styles.container, styles.disabled] : styles.container;
}, [disabled, styles.container, styles.disabled]);
let actionComponent;
let radioComponent;
if (type === OptionTypeConst.SELECT && selected) {
@ -297,7 +308,7 @@ const OptionItem = ({
const component = (
<View
testID={testID}
style={styles.container}
style={containerStyle}
onLayout={onLayout}
>
<View style={labelContainerStyle}>

View file

@ -6,6 +6,7 @@ import React from 'react';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeIsChannelAutotranslated} from '@queries/servers/channel';
import {queryAllCustomEmojis} from '@queries/servers/custom_emoji';
import {observeSavedPostsByIds, observeIsPostAcknowledgementsEnabled} from '@queries/servers/post';
import {observeConfigBooleanValue} from '@queries/servers/system';
@ -18,8 +19,13 @@ import PostList from './post_list';
import type {WithDatabaseArgs} from '@typings/database/database';
import type PostModel from '@typings/database/models/servers/post';
const enhancedWithoutPosts = withObservables([], ({database}: WithDatabaseArgs) => {
type OwnProps = {
channelId: string;
} & WithDatabaseArgs;
const enhancedWithoutPosts = withObservables(['channelId'], ({database, channelId}: OwnProps) => {
const currentUser = observeCurrentUser(database);
const isChannelAutotranslated = observeIsChannelAutotranslated(database, channelId);
return {
appsEnabled: observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'),
currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user?.timezone || null))))),
@ -29,6 +35,7 @@ const enhancedWithoutPosts = withObservables([], ({database}: WithDatabaseArgs)
switchMap((customEmojis) => of$(mapCustomEmojiNames(customEmojis))),
),
isPostAcknowledgementEnabled: observeIsPostAcknowledgementsEnabled(database),
isChannelAutotranslated,
};
});

View file

@ -31,6 +31,7 @@ exports[`AttachmentAuthor it matches snapshot when both name and icon are provid
{
"cacheKey": "attachment-author-icon-aHR0cHM6Ly9pbWFnZXMuY29tL2ltYWdlLnBuZw==",
"cachePath": "",
"headers": undefined,
"uri": "https://images.com/image.png",
},
]
@ -95,6 +96,7 @@ exports[`AttachmentAuthor it matches snapshot when only icon is provided 1`] = `
{
"cacheKey": "attachment-author-icon-aHR0cHM6Ly9pbWFnZXMuY29tL2ltYWdlLnBuZw==",
"cachePath": "",
"headers": undefined,
"uri": "https://images.com/image.png",
},
]

View file

@ -33,6 +33,7 @@ exports[`AttachmentFooter it matches snapshot when both footer and footer_icon a
{
"cacheKey": "attachment-footer-icon-aHR0cHM6Ly9pbWFnZXMuY29tL2ltYWdlLnBuZw==",
"cachePath": "",
"headers": undefined,
"uri": "https://images.com/image.png",
},
]

View file

@ -46,6 +46,7 @@ type BodyProps = {
searchPatterns?: SearchPattern[];
showAddReaction?: boolean;
theme: Theme;
isChannelAutotranslated: boolean;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
@ -89,9 +90,25 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
});
const Body = ({
appsEnabled, hasFiles, hasReactions, highlight, highlightReplyBar,
isCRTEnabled, isEphemeral, isFirstReply, isJumboEmoji, isLastReply, isPendingOrFailed, isPostAcknowledgementEnabled, isPostAddChannelMember,
location, post, searchPatterns, showAddReaction, theme,
appsEnabled,
hasFiles,
hasReactions,
highlight,
highlightReplyBar,
isCRTEnabled,
isEphemeral,
isFirstReply,
isJumboEmoji,
isLastReply,
isPendingOrFailed,
isPostAcknowledgementEnabled,
isPostAddChannelMember,
location,
post,
searchPatterns,
showAddReaction,
theme,
isChannelAutotranslated,
}: BodyProps) => {
const intl = useIntl();
const style = getStyleSheet(theme);
@ -180,12 +197,14 @@ const Body = ({
post={post}
searchPatterns={searchPatterns}
theme={theme}
isChannelAutotranslated={isChannelAutotranslated}
/>
);
}
const acknowledgementsVisible = isPostAcknowledgementEnabled && post.metadata?.priority?.requested_ack;
const reactionsVisible = hasReactions && showAddReaction;
if (!hasBeenDeleted) {
body = (
<View style={style.messageBody}>

View file

@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import React, {useCallback, useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {type LayoutChangeEvent, ScrollView, useWindowDimensions, View} from 'react-native';
import Animated from 'react-native-reanimated';
@ -9,6 +10,7 @@ import Markdown from '@components/markdown';
import {isChannelMentions} from '@components/markdown/channel_mention/channel_mention';
import {SEARCH} from '@constants/screens';
import {useShowMoreAnimatedStyle} from '@hooks/show_more';
import {getPostTranslatedMessage, getPostTranslation} from '@utils/post';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@ -31,6 +33,7 @@ type MessageProps = {
post: PostModel;
searchPatterns?: SearchPattern[];
theme: Theme;
isChannelAutotranslated: boolean;
}
const SHOW_MORE_HEIGHT = 54;
@ -56,13 +59,27 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const Message = ({currentUser, isHighlightWithoutNotificationLicensed, highlight, isEdited, isPendingOrFailed, isReplyPost, layoutWidth, location, post, searchPatterns, theme}: MessageProps) => {
const Message = ({
currentUser,
isHighlightWithoutNotificationLicensed,
highlight,
isEdited,
isPendingOrFailed,
isReplyPost,
layoutWidth,
location,
post,
searchPatterns,
theme,
isChannelAutotranslated,
}: MessageProps) => {
const [open, setOpen] = useState(false);
const [height, setHeight] = useState<number|undefined>();
const dimensions = useWindowDimensions();
const maxHeight = Math.round((dimensions.height * 0.5) + SHOW_MORE_HEIGHT);
const animatedStyle = useShowMoreAnimatedStyle(height, maxHeight, open);
const style = getStyleSheet(theme);
const intl = useIntl();
// We need to memoize these two values because they are actually getters that return a new list
// on every render. We need to trust that changes in the currentUser will trigger the recalculation.
@ -86,6 +103,12 @@ const Message = ({currentUser, isHighlightWithoutNotificationLicensed, highlight
return isChannelMentions(post.props?.channel_mentions) ? post.props.channel_mentions : {};
}, [post.props?.channel_mentions]);
const translation = getPostTranslation(post, intl.locale);
let message = post.message;
if (isChannelAutotranslated && post.type === '' && translation?.state === 'ready') {
message = getPostTranslatedMessage(post.message, translation);
}
return (
<>
<Animated.View style={animatedStyle}>
@ -110,7 +133,7 @@ const Message = ({currentUser, isHighlightWithoutNotificationLicensed, highlight
layoutWidth={layoutWidth}
location={location}
postId={post.id}
value={post.message}
value={message}
mentionKeys={mentionKeys}
highlightKeys={highlightKeys}
searchPatterns={searchPatterns}

View file

@ -9,13 +9,14 @@ import {revealBoRPost} from '@actions/remote/post';
import Button from '@components/button';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {PostModel} from '@database/models/server';
import {getFullErrorMessage, getServerError} from '@utils/errors';
import {showBoRPostErrorSnackbar} from '@utils/snack_bar';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {BOR_ERROR_CODES} from './constants';
import type PostModel from '@typings/database/models/servers/post';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
buttonBackgroundStyle: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),

View file

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

View file

@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {View} from 'react-native';
import {removePost} from '@actions/local/post';
@ -15,7 +16,7 @@ import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {DEFAULT_LOCALE} from '@i18n';
import {isUnrevealedBoRPost} from '@utils/bor';
import {postUserDisplayName} from '@utils/post';
import {getPostTranslation, postUserDisplayName} from '@utils/post';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {ensureString} from '@utils/types';
import {typography} from '@utils/typography';
@ -25,6 +26,7 @@ import HeaderCommentedOn from './commented_on';
import HeaderDisplayName from './display_name';
import HeaderReply from './reply';
import HeaderTag from './tag';
import TranslateIcon from './translate_icon';
import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
@ -50,6 +52,7 @@ type HeaderProps = {
shouldRenderReplyButton?: boolean;
teammateNameDisplay: string;
hideGuestTags: boolean;
isChannelAutotranslated: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
@ -80,12 +83,28 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const Header = (props: HeaderProps) => {
const {
author, commentCount = 0, currentUser, enablePostUsernameOverride, isAutoResponse, isCRTEnabled, isCustomStatusEnabled,
isEphemeral, isMilitaryTime, isPendingOrFailed, isSystemPost, isWebHook,
location, post, rootPostAuthor, showPostPriority, shouldRenderReplyButton, teammateNameDisplay, hideGuestTags,
} = props;
const Header = ({
commentCount,
enablePostUsernameOverride,
hideGuestTags,
isAutoResponse,
isChannelAutotranslated,
isCustomStatusEnabled,
isEphemeral,
isMilitaryTime,
isPendingOrFailed,
isSystemPost,
isWebHook,
location,
post,
showPostPriority,
teammateNameDisplay,
author,
currentUser,
isCRTEnabled,
rootPostAuthor,
shouldRenderReplyButton,
}: HeaderProps) => {
const theme = useTheme();
const style = getStyleSheet(theme);
const pendingPostStyle = isPendingOrFailed ? style.pendingPost : undefined;
@ -100,6 +119,7 @@ const Header = (props: HeaderProps) => {
) && !isCustomStatusExpired(author) && Boolean(customStatus?.emoji);
const userIconOverride = ensureString(post.props?.override_icon_url);
const usernameOverride = ensureString(post.props?.override_username);
const intl = useIntl();
const showBoRIcon = useMemo(() => isUnrevealedBoRPost(post), [post, post.metadata?.expire_at]);
const borExpireAt = post.metadata?.expire_at;
@ -109,6 +129,8 @@ const Header = (props: HeaderProps) => {
await removePost(serverUrl, post);
}, [post, serverUrl]);
const translation = getPostTranslation(post, intl.locale);
return (
<>
<View style={[style.container, pendingPostStyle]}>
@ -141,6 +163,9 @@ const Header = (props: HeaderProps) => {
style={style.time}
testID='post_header.date_time'
/>
{isChannelAutotranslated && post.type === '' &&
<TranslateIcon translationState={translation?.state}/>
}
{isEphemeral && (
<FormattedText
id='post_header.visible_message'

View file

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

View file

@ -7,6 +7,7 @@ import {of as of$, combineLatest} from 'rxjs';
import {switchMap, distinctUntilChanged} from 'rxjs/operators';
import {Permissions, Preferences, Screens} from '@constants';
import {DEFAULT_LOCALE} from '@i18n';
import {queryFilesForPost} from '@queries/servers/file';
import {observePost, observePostAuthor, queryPostsBetween, observeIsPostPriorityEnabled} from '@queries/servers/post';
import {queryReactionsForPost} from '@queries/servers/reaction';
@ -86,6 +87,21 @@ function isFirstReply(post: PostModel, previousPost?: PostModel) {
return false;
}
function observeIsConsecutivePost(database: Database, post: PostModel, userLocale: string, previousPost?: PostModel) {
if (isBoRPost(post)) {
return of$(false);
}
if (!post ||!previousPost) {
return of$(false);
}
const author = post.userId ? observePostAuthor(database, post) : of$(undefined);
return author.pipe(
switchMap((user) => of$(Boolean(!user?.isBot && areConsecutivePosts(post, previousPost, userLocale)))),
distinctUntilChanged(),
);
}
const withSystem = withObservables([], ({database}: WithDatabaseArgs) => ({
currentUser: observeCurrentUser(database),
}));
@ -96,7 +112,6 @@ const withPost = withObservables(
let isLastReply = of$(true);
let isPostAddChannelMember = of$(false);
const isOwner = currentUser?.id === post.userId;
const author = post.userId ? observePostAuthor(database, post) : of$(undefined);
const canDelete = observePermissionForPost(database, post, currentUser, isOwner ? Permissions.DELETE_POST : Permissions.DELETE_OTHERS_POSTS, false);
const isEphemeral = of$(isPostEphemeral(post));
@ -127,10 +142,7 @@ const withPost = withObservables(
// Don't combine consecutive Burn on Read posts as we want each BoR post
// to display its header to allow displaying the remaining time.
const isConsecutivePost = isBoRPost(post) ? of$(false) : author.pipe(
switchMap((user) => of$(Boolean(post && previousPost && !user?.isBot && areConsecutivePosts(post, previousPost)))),
distinctUntilChanged(),
);
const isConsecutivePost = observeIsConsecutivePost(database, post, currentUser?.locale || DEFAULT_LOCALE, previousPost);
const hasFiles = queryFilesForPost(database, post.id).observeCount().pipe(
switchMap((c) => of$(c > 0)),

View file

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

View file

@ -5,7 +5,7 @@ import AgentPost from '@agents/components/agent_post';
import {isAgentPost} from '@agents/utils';
import React, {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Platform, type StyleProp, View, type ViewStyle, TouchableHighlight} from 'react-native';
import {Platform, type StyleProp, View, type ViewStyle, TouchableHighlight, type LayoutChangeEvent} from 'react-native';
import {KeyboardController} from 'react-native-keyboard-controller';
import {removePost} from '@actions/local/post';
@ -34,8 +34,10 @@ import Body from './body';
import Footer from './footer';
import Header from './header';
import PreHeader from './pre_header';
import ShimmerAnimation from './shimmer_animation';
import SystemMessage from './system_message';
import UnreadDot from './unread_dot';
import useShimmerAnimation from './use_shimmer_animation';
import type PostModel from '@typings/database/models/servers/post';
import type ThreadModel from '@typings/database/models/servers/thread';
@ -77,6 +79,7 @@ type PostProps = {
style?: StyleProp<ViewStyle>;
testID?: string;
thread?: ThreadModel;
isChannelAutotranslated: boolean;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
@ -150,6 +153,7 @@ const Post = ({
thread,
previousPost,
isLastPost,
isChannelAutotranslated,
}: PostProps) => {
const pressDetected = useRef(false);
const intl = useIntl();
@ -169,6 +173,8 @@ const Post = ({
const isAgentPostType = isAgentPost(post);
const hasBeenDeleted = (post.deleteAt !== 0);
const isWebHook = isFromWebhook(post);
const [layoutWidth, setLayoutWidth] = useState(0);
const shimmerAnimationProps = useShimmerAnimation(post, isChannelAutotranslated, intl.locale, layoutWidth, theme);
const hasSameRoot = useMemo(() => {
if (isFirstReply) {
return false;
@ -285,6 +291,10 @@ const Post = ({
// eslint-disable-next-line react-hooks/exhaustive-deps -- Performance metrics should only run once on mount
}, []);
const onLayout = useCallback((e: LayoutChangeEvent) => {
setLayoutWidth(e.nativeEvent.layout.width);
}, []);
const highlightSaved = isSaved && !skipSavedHeader;
const hightlightPinned = post.isPinned && !skipPinnedHeader;
const itemTestID = `${testID}.${post.id}`;
@ -349,6 +359,7 @@ const Post = ({
post={post}
showPostPriority={showPostPriority}
shouldRenderReplyButton={shouldRenderReplyButton}
isChannelAutotranslated={isChannelAutotranslated}
/>
);
}
@ -408,6 +419,7 @@ const Post = ({
searchPatterns={searchPatterns}
showAddReaction={showAddReaction}
theme={theme}
isChannelAutotranslated={isChannelAutotranslated}
/>
);
}
@ -435,6 +447,7 @@ const Post = ({
<View
testID={testID}
style={[styles.postStyle, style, highlightedStyle]}
onLayout={onLayout}
>
<TouchableHighlight
testID={itemTestID}
@ -463,6 +476,7 @@ const Post = ({
</View>
</>
</TouchableHighlight>
<ShimmerAnimation {...shimmerAnimationProps}/>
</View>
);
};

View file

@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {LinearGradient} from 'expo-linear-gradient';
import {StyleSheet, View} from 'react-native';
import Animated from 'react-native-reanimated';
import type {ComponentProps} from 'react';
type Props = {
isTranslating: boolean;
backgroundColor: string;
shimmerAnimatedStyle: ComponentProps<typeof Animated.View>['style'];
gradientColors: ComponentProps<typeof LinearGradient>['colors'];
};
const shimmerStyles = StyleSheet.create({
shimmerContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
overflow: 'hidden',
},
shimmerBackground: {
...StyleSheet.absoluteFillObject,
},
shimmerWrapper: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
width: '300%',
},
});
const gradientSettings = {
locations: [0, 0.3, 0.5, 0.7, 1] as const,
start: {x: 0, y: 0},
end: {x: 0.766, y: 0.643}, // 130 degree angle (more severe)
};
const ShimmerAnimation = ({
isTranslating,
backgroundColor,
shimmerAnimatedStyle,
gradientColors,
}: Props) => {
if (!isTranslating) {
return null;
}
return (
<View
style={shimmerStyles.shimmerContainer}
pointerEvents='none'
>
<View style={[shimmerStyles.shimmerBackground, {backgroundColor}]}/>
<Animated.View style={[shimmerStyles.shimmerWrapper, shimmerAnimatedStyle]}>
<LinearGradient
colors={gradientColors}
locations={gradientSettings.locations}
start={gradientSettings.start}
end={gradientSettings.end}
style={StyleSheet.absoluteFill}
/>
</Animated.View>
</View>
);
};
export default ShimmerAnimation;

View file

@ -0,0 +1,76 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useMemo} from 'react';
import {cancelAnimation, Easing, interpolate, useAnimatedStyle, useSharedValue, withRepeat, withTiming} from 'react-native-reanimated';
import EphemeralStore from '@store/ephemeral_store';
import {getPostTranslation} from '@utils/post';
import {changeOpacity} from '@utils/theme';
import type PostModel from '@typings/database/models/servers/post';
const MAX_RUNNING_TRANSLATIONS = 10;
const useShimmerAnimation = (post: PostModel, isChannelAutotranslated: boolean, locale: string, layoutWidth: number, theme: Theme) => {
const translation = getPostTranslation(post, locale);
const isTranslating = isChannelAutotranslated && post.type === '' && translation?.state === 'processing';
const shimmerTranslateX = useSharedValue(-1);
useEffect(() => {
if (isTranslating) {
if (EphemeralStore.totalRunningTranslations() < MAX_RUNNING_TRANSLATIONS) {
EphemeralStore.addRunningTranslation(post.id);
shimmerTranslateX.value = withRepeat(
withTiming(1, {
duration: 2000,
easing: Easing.linear,
}),
-1,
);
} else {
shimmerTranslateX.value = 0;
}
}
return () => {
EphemeralStore.removeRunningTranslation(post.id);
cancelAnimation(shimmerTranslateX);
shimmerTranslateX.value = -1;
};
}, [isTranslating, post.id, shimmerTranslateX]);
const shimmerAnimatedStyle = useAnimatedStyle(() => {
const translateX = interpolate(
shimmerTranslateX.value,
[-1, 1],
[-4 * layoutWidth, 4 * layoutWidth],
);
return {
transform: [{translateX}],
};
});
const gradientColors = useMemo(() => {
return [
changeOpacity(theme.centerChannelBg, 0.0),
changeOpacity(theme.centerChannelBg, 0.4),
theme.centerChannelBg,
changeOpacity(theme.centerChannelBg, 0.4),
changeOpacity(theme.centerChannelBg, 0.0),
] as const;
}, [theme]);
const backgroundColor = useMemo(() => {
return changeOpacity(theme.centerChannelBg, 0.32);
}, [theme]);
return {
backgroundColor,
gradientColors,
shimmerAnimatedStyle,
isTranslating,
};
};
export default useShimmerAnimation;

View file

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

View file

@ -57,6 +57,7 @@ type Props = {
testID: string;
currentCallBarVisible?: boolean;
savedPostIds: Set<string>;
isChannelAutotranslated: boolean;
listRef?: React.RefObject<FlatList<string | PostModel>>;
onTouchMove?: (event: GestureResponderEvent) => void;
onTouchEnd?: () => void;
@ -110,6 +111,7 @@ const PostList = ({
showNewMessageLine = true,
testID,
savedPostIds,
isChannelAutotranslated,
listRef,
onTouchMove,
onTouchEnd,
@ -372,6 +374,7 @@ const PostList = ({
shouldRenderReplyButton,
skipSaveddHeader,
testID: `${testID}.post`,
isChannelAutotranslated,
};
return (
@ -382,7 +385,7 @@ const PostList = ({
);
}
}
}, [appsEnabled, currentTimezone, currentUsername, customEmojiNames, highlightPinnedOrSaved, highlightedId, isCRTEnabled, isPostAcknowledgementEnabled, location, rootId, shouldRenderReplyButton, shouldShowJoinLeaveMessages, testID, theme]);
}, [appsEnabled, currentTimezone, currentUsername, customEmojiNames, highlightPinnedOrSaved, highlightedId, isCRTEnabled, isChannelAutotranslated, isPostAcknowledgementEnabled, location, rootId, shouldRenderReplyButton, shouldShowJoinLeaveMessages, testID, theme]);
useEffect(() => {
const t = setTimeout(() => {

View file

@ -4,6 +4,7 @@
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {of as of$} from 'rxjs';
import {observeIsChannelAutotranslated} from '@queries/servers/channel';
import {observePostSaved} from '@queries/servers/post';
import {observeIsCRTEnabled} from '@queries/servers/thread';
@ -21,6 +22,7 @@ const enhance = withObservables(['post', 'skipSavedPostsHighlight'], ({database,
return {
isCRTEnabled: observeIsCRTEnabled(database),
isSaved: skipSavedPostsHighlight ? of$(false) : observePostSaved(database, post.id),
isChannelAutotranslated: observeIsChannelAutotranslated(database, post.channelId),
};
});

View file

@ -22,6 +22,7 @@ type Props = {
searchPatterns?: SearchPattern[];
skipSavedPostsHighlight?: boolean;
isSaved?: boolean;
isChannelAutotranslated: boolean;
}
const styles = StyleSheet.create({
@ -35,7 +36,18 @@ const styles = StyleSheet.create({
},
});
function PostWithChannelInfo({appsEnabled, customEmojiNames, isCRTEnabled, post, location, testID, searchPatterns, skipSavedPostsHighlight = false, isSaved}: Props) {
function PostWithChannelInfo({
appsEnabled,
customEmojiNames,
isCRTEnabled,
post,
location,
testID,
searchPatterns,
skipSavedPostsHighlight = false,
isSaved,
isChannelAutotranslated,
}: Props) {
return (
<View style={styles.container}>
<ChannelInfo
@ -59,6 +71,7 @@ function PostWithChannelInfo({appsEnabled, customEmojiNames, isCRTEnabled, post,
nextPost={undefined}
testID={`${testID}.post`}
isSaved={isSaved}
isChannelAutotranslated={isChannelAutotranslated}
/>
</View>
</View>

View file

@ -809,6 +809,7 @@ exports[`components/channel_list_row should show results and tutorial 1`] = `
{
"cacheKey": "user-1-123456",
"cachePath": "",
"headers": undefined,
"uri": "https://community.mattermost.com/api/v4/users/1/image?_=123456",
},
]
@ -1152,6 +1153,7 @@ exports[`components/channel_list_row should show results no tutorial 1`] = `
{
"cacheKey": "user-1-123456",
"cachePath": "",
"headers": undefined,
"uri": "https://community.mattermost.com/api/v4/users/1/image?_=123456",
},
]
@ -1533,6 +1535,7 @@ exports[`components/channel_list_row should show results no tutorial 2 users 1`]
{
"cacheKey": "user-1-123456",
"cachePath": "",
"headers": undefined,
"uri": "https://community.mattermost.com/api/v4/users/1/image?_=123456",
},
]
@ -1775,6 +1778,7 @@ exports[`components/channel_list_row should show results no tutorial 2 users 1`]
{
"cacheKey": "user-2-123456",
"cachePath": "",
"headers": undefined,
"uri": "https://community.mattermost.com/api/v4/users/2/image?_=123456",
},
]

View file

@ -37,6 +37,7 @@ export default keyMirror({
ACTIVE_SCREEN: null,
ACTIVE_SERVER_CHANGED: null,
FILE_ADD_REMOVED: null,
POST_DELETED_FOR_CHANNEL: null,
KEYBOARD_STATE_CHANGED: null,
CLOSE_INPUT_ACCESSORY_VIEW: null,
EMOJI_PICKER_SEARCH_FOCUSED: null,

View file

@ -159,4 +159,8 @@ export default {
EDIT_BOOKMARK_PRIVATE_CHANNEL: 'edit_bookmark_private_channel',
DELETE_BOOKMARK_PUBLIC_CHANNEL: 'delete_bookmark_public_channel',
DELETE_BOOKMARK_PRIVATE_CHANNEL: 'delete_bookmark_private_channel',
// Autotranslations
MANAGE_PUBLIC_CHANNEL_AUTO_TRANSLATION: 'manage_public_channel_auto_translation',
MANAGE_PRIVATE_CHANNEL_AUTO_TRANSLATION: 'manage_private_channel_auto_translation',
};

View file

@ -19,6 +19,7 @@ export const CHANNEL_BOOKMARK = 'ChannelBookmarkAddOrEdit';
export const CHANNEL_FILES = 'ChannelFiles';
export const CHANNEL_INFO = 'ChannelInfo';
export const CHANNEL_NOTIFICATION_PREFERENCES = 'ChannelNotificationPreferences';
export const CHANNEL_SETTINGS = 'ChannelSettings';
export const CODE = 'Code';
export const CONVERT_GM_TO_CHANNEL = 'ConvertGMToChannel';
export const CREATE_DIRECT_MESSAGE = 'CreateDirectMessage';
@ -91,6 +92,7 @@ export const THREAD = 'Thread';
export const THREAD_FOLLOW_BUTTON = 'ThreadFollowButton';
export const THREAD_OPTIONS = 'ThreadOptions';
export const USER_PROFILE = 'UserProfile';
export const SHOW_TRANSLATION = 'ShowTranslation';
export default {
ABOUT,
@ -109,6 +111,7 @@ export default {
CHANNEL_FILES,
CHANNEL_INFO,
CHANNEL_NOTIFICATION_PREFERENCES,
CHANNEL_SETTINGS,
CODE,
CONVERT_GM_TO_CHANNEL,
COMPONENT_LIBRARY,
@ -180,6 +183,7 @@ export default {
THREAD_FOLLOW_BUTTON,
THREAD_OPTIONS,
USER_PROFILE,
SHOW_TRANSLATION,
...PLAYBOOKS_SCREENS,
} as const;

View file

@ -29,6 +29,7 @@ export const SNACK_BAR_TYPE = keyMirror({
RESCHEDULED_POST: null,
DELETE_SCHEDULED_POST_ERROR: null,
PLAYBOOK_ERROR: null,
ENABLE_TRANSLATION: null,
BOR_POST_EXPIRED: null,
});
@ -41,7 +42,7 @@ export const MESSAGE_TYPE = {
export type SnackBarConfig = {
message: MessageDescriptor;
iconName: string;
canUndo: boolean;
hasAction: boolean;
type?: typeof MESSAGE_TYPE[keyof typeof MESSAGE_TYPE];
};
@ -118,6 +119,10 @@ const messages = defineMessages({
id: 'snack.bar.playbook.error',
defaultMessage: 'Unable to perform action. Please try again later.',
},
ENABLE_TRANSLATION: {
id: 'snack.bar.enable.translation',
defaultMessage: 'Enable auto-translation?',
},
BOR_POST_EXPIRED: {
id: 'snack.bar.bor_post_expired.error',
defaultMessage: 'This burn-on-read post has expired and can no longer be revealed.',
@ -128,104 +133,109 @@ export const SNACK_BAR_CONFIG: Record<string, SnackBarConfig> = {
ADD_CHANNEL_MEMBERS: {
message: messages.ADD_CHANNEL_MEMBERS,
iconName: 'check',
canUndo: false,
hasAction: false,
},
AGENT_STOP_ERROR: {
message: messages.AGENT_STOP_ERROR,
iconName: 'alert-outline',
canUndo: false,
hasAction: false,
type: MESSAGE_TYPE.ERROR,
},
AGENT_REGENERATE_ERROR: {
message: messages.AGENT_REGENERATE_ERROR,
iconName: 'alert-outline',
canUndo: false,
hasAction: false,
type: MESSAGE_TYPE.ERROR,
},
AGENT_TOOL_APPROVAL_ERROR: {
message: messages.AGENT_TOOL_APPROVAL_ERROR,
iconName: 'alert-outline',
canUndo: false,
hasAction: false,
type: MESSAGE_TYPE.ERROR,
},
CODE_COPIED: {
message: messages.CODE_COPIED,
iconName: 'content-copy',
canUndo: false,
hasAction: false,
},
FAVORITE_CHANNEL: {
message: messages.FAVORITE_CHANNEL,
iconName: 'star',
canUndo: true,
hasAction: true,
},
FOLLOW_THREAD: {
message: messages.FOLLOW_THREAD,
iconName: 'check',
canUndo: true,
hasAction: true,
},
INFO_COPIED: {
message: messages.INFO_COPIED,
iconName: 'content-copy',
canUndo: false,
hasAction: false,
},
LINK_COPIED: {
message: messages.LINK_COPIED,
iconName: 'link-variant',
canUndo: false,
hasAction: false,
type: MESSAGE_TYPE.SUCCESS,
},
LINK_COPY_FAILED: {
message: messages.LINK_COPY_FAILED,
iconName: 'link-variant',
canUndo: false,
hasAction: false,
type: MESSAGE_TYPE.ERROR,
},
MESSAGE_COPIED: {
message: messages.MESSAGE_COPIED,
iconName: 'content-copy',
canUndo: false,
hasAction: false,
},
MUTE_CHANNEL: {
message: messages.MUTE_CHANNEL,
iconName: 'bell-off-outline',
canUndo: true,
hasAction: true,
},
REMOVE_CHANNEL_USER: {
message: messages.REMOVE_CHANNEL_USER,
iconName: 'check',
canUndo: true,
hasAction: true,
},
TEXT_COPIED: {
message: messages.TEXT_COPIED,
iconName: 'content-copy',
canUndo: false,
hasAction: false,
type: MESSAGE_TYPE.SUCCESS,
},
UNFAVORITE_CHANNEL: {
message: messages.UNFAVORITE_CHANNEL,
iconName: 'star-outline',
canUndo: true,
hasAction: true,
},
UNMUTE_CHANNEL: {
message: messages.UNMUTE_CHANNEL,
iconName: 'bell-outline',
canUndo: true,
hasAction: true,
},
UNFOLLOW_THREAD: {
message: messages.UNFOLLOW_THREAD,
iconName: 'check',
canUndo: true,
hasAction: true,
},
PLAYBOOK_ERROR: {
message: messages.PLAYBOOK_ERROR,
iconName: 'alert-outline',
canUndo: false,
hasAction: false,
type: MESSAGE_TYPE.ERROR,
},
ENABLE_TRANSLATION: {
message: messages.ENABLE_TRANSLATION,
iconName: 'globe',
hasAction: true,
},
BOR_POST_EXPIRED: {
message: messages.BOR_POST_EXPIRED,
iconName: 'alert-outline',
canUndo: false,
hasAction: false,
type: MESSAGE_TYPE.ERROR,
},
};

View file

@ -31,7 +31,7 @@ export const CALL_NOTIFICATION_BAR_HEIGHT = 40;
export const ANNOUNCEMENT_BAR_HEIGHT = 40;
export const BOOKMARKS_BAR_HEIGHT = 48;
export const CHANNEL_BANNER_HEIGHT = 32;
export const CHANNEL_BANNER_HEIGHT = 26;
export const HOME_PADDING = {
paddingLeft: 18,

View file

@ -61,6 +61,7 @@ const WebsocketEvents = {
DELETE_TEAM: 'delete_team',
RESTORE_TEAM: 'restore_team',
APPS_FRAMEWORK_REFRESH_BINDINGS: 'custom_com.mattermost.apps_refresh_bindings',
POST_TRANSLATION_UPDATED: 'post_translation_updated',
CALLS_CHANNEL_ENABLED: `custom_${Calls.PluginId}_channel_enable_voice`,
CALLS_CHANNEL_DISABLED: `custom_${Calls.PluginId}_channel_disable_voice`,

View file

@ -25,6 +25,23 @@ const {
const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM, PLAYBOOK_RUN_ATTRIBUTE, PLAYBOOK_RUN_ATTRIBUTE_VALUE} = PLAYBOOK_TABLES;
export default schemaMigrations({migrations: [
{
toVersion: 18,
steps: [
addColumns({
table: MY_CHANNEL,
columns: [
{name: 'autotranslation_disabled', type: 'boolean'},
],
}),
addColumns({
table: CHANNEL,
columns: [
{name: 'autotranslation', type: 'boolean'},
],
}),
],
},
{
toVersion: 17,
steps: [

View file

@ -155,6 +155,9 @@ export default class ChannelModel extends Model implements ChannelModelInterface
/** categoryChannel : Query returning the membership data for the current user if it belongs to this channel */
@immutableRelation(CATEGORY_CHANNEL, 'channel_id') categoryChannel!: Relation<CategoryChannelModel>;
/** autotranslation : Whether the channel has automatic translation enabled */
@field('autotranslation') autotranslation!: boolean;
toApi = (): Channel => {
return {
id: this.id,
@ -176,6 +179,7 @@ export default class ChannelModel extends Model implements ChannelModelInterface
shared: this.shared,
banner_info: this.bannerInfo,
policy_enforced: this.abacPolicyEnforced,
autotranslation: this.autotranslation,
};
};
}

View file

@ -56,6 +56,9 @@ export default class MyChannelModel extends Model implements MyChannelModelInter
/** last_playbook_runs_fetch_at : The timestamp of the last successful fetch of playbook runs for this channel, used as the "since" parameter for incremental updates */
@field('last_playbook_runs_fetch_at') lastPlaybookRunsFetchAt!: number;
/* autotranslation: Determines if the channel has automatic translation enabled for this user*/
@field('autotranslation_disabled') autotranslationDisabled!: boolean;
/** channel : The relation pointing to the CHANNEL table */
@immutableRelation(CHANNEL, 'id') channel!: Relation<ChannelModel>;

View file

@ -279,7 +279,7 @@ const ChannelHandler = <TBase extends Constructor<ServerDataOperatorBase>>(super
const lastPostAt = isCRT ? (chan.last_root_post_at || chan.last_post_at) : chan.last_post_at;
if ((chan && e.lastPostAt < lastPostAt) ||
e.isUnread !== my.is_unread || e.lastViewedAt < my.last_viewed_at ||
e.roles !== my.roles
e.roles !== my.roles || e.autotranslationDisabled !== my.autotranslation_disabled
) {
res.push(my);
}

View file

@ -53,6 +53,7 @@ export const transformChannelRecord = ({action, database, value}: TransformerArg
channel.type = raw.type;
channel.bannerInfo = raw.banner_info;
channel.abacPolicyEnforced = Boolean(raw.policy_enforced);
channel.autotranslation = Boolean(raw.autotranslation);
};
return prepareBaseRecord({
@ -156,6 +157,7 @@ export const transformMyChannelRecord = async ({action, database, value}: Transf
myChannel.viewedAt = record?.viewedAt || 0;
myChannel.lastFetchedAt = record?.lastFetchedAt || 0;
myChannel.lastPlaybookRunsFetchAt = record?.lastPlaybookRunsFetchAt || 0;
myChannel.autotranslationDisabled = Boolean(raw.autotranslation_disabled);
};
return prepareBaseRecord({

View file

@ -45,7 +45,7 @@ import {
} from './table_schemas';
export const serverSchema: AppSchema = appSchema({
version: 17,
version: 18,
tables: [
CategorySchema,
CategoryChannelSchema,

View file

@ -22,5 +22,6 @@ export default tableSchema({
{name: 'update_at', type: 'number'},
{name: 'banner_info', type: 'string', isOptional: true},
{name: 'abac_policy_enforced', type: 'boolean', isOptional: true},
{name: 'autotranslation', type: 'boolean', isOptional: true},
],
});

View file

@ -20,6 +20,7 @@ export default tableSchema({
{name: 'viewed_at', type: 'number'},
{name: 'last_fetched_at', type: 'number', isIndexed: true},
{name: 'last_playbook_runs_fetch_at', type: 'number'},
{name: 'autotranslation_disabled', type: 'boolean', isOptional: true},
],
});

View file

@ -52,7 +52,7 @@ const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM, PLAYBOOK_RUN_A
describe('*** Test schema for SERVER database ***', () => {
it('=> The SERVER SCHEMA should strictly match', () => {
expect(serverSchema).toEqual({
version: 17,
version: 18,
unsafeSql: undefined,
tables: {
[CATEGORY]: {
@ -130,6 +130,7 @@ describe('*** Test schema for SERVER database ***', () => {
update_at: {name: 'update_at', type: 'number'},
banner_info: {name: 'banner_info', type: 'string', isOptional: true},
abac_policy_enforced: {name: 'abac_policy_enforced', type: 'boolean', isOptional: true},
autotranslation: {name: 'autotranslation', type: 'boolean', isOptional: true},
},
columnArray: [
{name: 'create_at', type: 'number'},
@ -144,6 +145,7 @@ describe('*** Test schema for SERVER database ***', () => {
{name: 'update_at', type: 'number'},
{name: 'banner_info', type: 'string', isOptional: true},
{name: 'abac_policy_enforced', type: 'boolean', isOptional: true},
{name: 'autotranslation', type: 'boolean', isOptional: true},
],
},
[CHANNEL_BOOKMARK]: {
@ -269,6 +271,7 @@ describe('*** Test schema for SERVER database ***', () => {
viewed_at: {name: 'viewed_at', type: 'number'},
last_fetched_at: {name: 'last_fetched_at', type: 'number', isIndexed: true},
last_playbook_runs_fetch_at: {name: 'last_playbook_runs_fetch_at', type: 'number'},
autotranslation_disabled: {name: 'autotranslation_disabled', type: 'boolean', isOptional: true},
},
columnArray: [
{name: 'is_unread', type: 'boolean'},
@ -281,6 +284,7 @@ describe('*** Test schema for SERVER database ***', () => {
{name: 'viewed_at', type: 'number'},
{name: 'last_fetched_at', type: 'number', isIndexed: true},
{name: 'last_playbook_runs_fetch_at', type: 'number'},
{name: 'autotranslation_disabled', type: 'boolean', isOptional: true},
],
},
[MY_CHANNEL_SETTINGS]: {

View file

@ -25,6 +25,7 @@ function loadTranslation(locale?: string): {[x: string]: string} {
require('@formatjs/intl-datetimeformat/locale-data/bg');
require('@formatjs/intl-listformat/locale-data/bg');
require('@formatjs/intl-relativetimeformat/locale-data/bg');
require('@formatjs/intl-displaynames/locale-data/bg');
translations = require('@assets/i18n/bg.json');
break;
@ -34,6 +35,7 @@ function loadTranslation(locale?: string): {[x: string]: string} {
require('@formatjs/intl-datetimeformat/locale-data/de');
require('@formatjs/intl-listformat/locale-data/de');
require('@formatjs/intl-relativetimeformat/locale-data/de');
require('@formatjs/intl-displaynames/locale-data/de');
translations = require('@assets/i18n/de.json');
break;
@ -43,6 +45,7 @@ function loadTranslation(locale?: string): {[x: string]: string} {
require('@formatjs/intl-datetimeformat/locale-data/en');
require('@formatjs/intl-listformat/locale-data/en');
require('@formatjs/intl-relativetimeformat/locale-data/en');
require('@formatjs/intl-displaynames/locale-data/en');
translations = require('@assets/i18n/en_AU.json');
break;
@ -52,6 +55,7 @@ function loadTranslation(locale?: string): {[x: string]: string} {
require('@formatjs/intl-datetimeformat/locale-data/es');
require('@formatjs/intl-listformat/locale-data/es');
require('@formatjs/intl-relativetimeformat/locale-data/es');
require('@formatjs/intl-displaynames/locale-data/es');
translations = require('@assets/i18n/es.json');
break;
@ -61,6 +65,7 @@ function loadTranslation(locale?: string): {[x: string]: string} {
require('@formatjs/intl-datetimeformat/locale-data/fa');
require('@formatjs/intl-listformat/locale-data/fa');
require('@formatjs/intl-relativetimeformat/locale-data/fa');
require('@formatjs/intl-displaynames/locale-data/fa');
translations = require('@assets/i18n/fa.json');
break;
@ -70,6 +75,7 @@ function loadTranslation(locale?: string): {[x: string]: string} {
require('@formatjs/intl-datetimeformat/locale-data/fr');
require('@formatjs/intl-listformat/locale-data/fr');
require('@formatjs/intl-relativetimeformat/locale-data/fr');
require('@formatjs/intl-displaynames/locale-data/fr');
translations = require('@assets/i18n/fr.json');
break;
@ -79,6 +85,7 @@ function loadTranslation(locale?: string): {[x: string]: string} {
require('@formatjs/intl-datetimeformat/locale-data/hu');
require('@formatjs/intl-listformat/locale-data/hu');
require('@formatjs/intl-relativetimeformat/locale-data/hu');
require('@formatjs/intl-displaynames/locale-data/hu');
translations = require('@assets/i18n/hu.json');
break;
@ -88,6 +95,7 @@ function loadTranslation(locale?: string): {[x: string]: string} {
require('@formatjs/intl-datetimeformat/locale-data/it');
require('@formatjs/intl-listformat/locale-data/it');
require('@formatjs/intl-relativetimeformat/locale-data/it');
require('@formatjs/intl-displaynames/locale-data/it');
translations = require('@assets/i18n/it.json');
break;
@ -97,6 +105,7 @@ function loadTranslation(locale?: string): {[x: string]: string} {
require('@formatjs/intl-datetimeformat/locale-data/ja');
require('@formatjs/intl-listformat/locale-data/ja');
require('@formatjs/intl-relativetimeformat/locale-data/ja');
require('@formatjs/intl-displaynames/locale-data/ja');
translations = require('@assets/i18n/ja.json');
break;
@ -106,6 +115,7 @@ function loadTranslation(locale?: string): {[x: string]: string} {
require('@formatjs/intl-datetimeformat/locale-data/ko');
require('@formatjs/intl-listformat/locale-data/ko');
require('@formatjs/intl-relativetimeformat/locale-data/ko');
require('@formatjs/intl-displaynames/locale-data/ko');
translations = require('@assets/i18n/ko.json');
break;
@ -115,6 +125,7 @@ function loadTranslation(locale?: string): {[x: string]: string} {
require('@formatjs/intl-datetimeformat/locale-data/nl');
require('@formatjs/intl-listformat/locale-data/nl');
require('@formatjs/intl-relativetimeformat/locale-data/nl');
require('@formatjs/intl-displaynames/locale-data/nl');
translations = require('@assets/i18n/nl.json');
break;
@ -124,6 +135,7 @@ function loadTranslation(locale?: string): {[x: string]: string} {
require('@formatjs/intl-datetimeformat/locale-data/pl');
require('@formatjs/intl-listformat/locale-data/pl');
require('@formatjs/intl-relativetimeformat/locale-data/pl');
require('@formatjs/intl-displaynames/locale-data/pl');
translations = require('@assets/i18n/pl.json');
break;
@ -133,6 +145,7 @@ function loadTranslation(locale?: string): {[x: string]: string} {
require('@formatjs/intl-datetimeformat/locale-data/pt');
require('@formatjs/intl-listformat/locale-data/pt');
require('@formatjs/intl-relativetimeformat/locale-data/pt');
require('@formatjs/intl-displaynames/locale-data/pt');
translations = require('@assets/i18n/pt-BR.json');
break;
@ -142,6 +155,7 @@ function loadTranslation(locale?: string): {[x: string]: string} {
require('@formatjs/intl-datetimeformat/locale-data/ro');
require('@formatjs/intl-listformat/locale-data/ro');
require('@formatjs/intl-relativetimeformat/locale-data/ro');
require('@formatjs/intl-displaynames/locale-data/ro');
translations = require('@assets/i18n/ro.json');
break;
@ -151,6 +165,7 @@ function loadTranslation(locale?: string): {[x: string]: string} {
require('@formatjs/intl-datetimeformat/locale-data/ru');
require('@formatjs/intl-listformat/locale-data/ru');
require('@formatjs/intl-relativetimeformat/locale-data/ru');
require('@formatjs/intl-displaynames/locale-data/ru');
translations = require('@assets/i18n/ru.json');
break;
@ -160,6 +175,7 @@ function loadTranslation(locale?: string): {[x: string]: string} {
require('@formatjs/intl-datetimeformat/locale-data/sv');
require('@formatjs/intl-listformat/locale-data/sv');
require('@formatjs/intl-relativetimeformat/locale-data/sv');
require('@formatjs/intl-displaynames/locale-data/sv');
translations = require('@assets/i18n/sv.json');
break;
@ -169,6 +185,7 @@ function loadTranslation(locale?: string): {[x: string]: string} {
require('@formatjs/intl-datetimeformat/locale-data/tr');
require('@formatjs/intl-listformat/locale-data/tr');
require('@formatjs/intl-relativetimeformat/locale-data/tr');
require('@formatjs/intl-displaynames/locale-data/tr');
translations = require('@assets/i18n/tr.json');
break;
@ -178,6 +195,7 @@ function loadTranslation(locale?: string): {[x: string]: string} {
require('@formatjs/intl-datetimeformat/locale-data/uk');
require('@formatjs/intl-listformat/locale-data/uk');
require('@formatjs/intl-relativetimeformat/locale-data/uk');
require('@formatjs/intl-displaynames/locale-data/uk');
translations = require('@assets/i18n/uk.json');
break;
@ -187,6 +205,7 @@ function loadTranslation(locale?: string): {[x: string]: string} {
require('@formatjs/intl-datetimeformat/locale-data/vi');
require('@formatjs/intl-listformat/locale-data/vi');
require('@formatjs/intl-relativetimeformat/locale-data/vi');
require('@formatjs/intl-displaynames/locale-data/vi');
translations = require('@assets/i18n/vi.json');
break;
@ -204,6 +223,7 @@ function loadTranslation(locale?: string): {[x: string]: string} {
require('@formatjs/intl-datetimeformat/locale-data/en');
require('@formatjs/intl-listformat/locale-data/en');
require('@formatjs/intl-relativetimeformat/locale-data/en');
require('@formatjs/intl-displaynames/locale-data/en');
translations = en;
break;
@ -222,6 +242,7 @@ function loadChinesePolyfills() {
require('@formatjs/intl-datetimeformat/locale-data/zh');
require('@formatjs/intl-listformat/locale-data/zh');
require('@formatjs/intl-relativetimeformat/locale-data/zh');
require('@formatjs/intl-displaynames/locale-data/zh');
}
export function getLocaleFromLanguage(lang: string) {

View file

@ -7,6 +7,7 @@ import {Alert, DeviceEventEmitter, Linking, NativeEventEmitter} from 'react-nati
import semver from 'semver';
import {switchToChannelById} from '@actions/remote/channel';
import {batchTeamThreadSync} from '@actions/remote/thread';
import {Device, Events, Sso} from '@constants';
import {MIN_REQUIRED_VERSION} from '@constants/supported_server';
import DatabaseManager from '@database/manager';
@ -46,6 +47,7 @@ class GlobalEventHandlerSingleton {
DeviceEventEmitter.addListener(Events.SERVER_VERSION_CHANGED, this.onServerVersionChanged);
splitViewEmitter.addListener('SplitViewChanged', this.onSplitViewChanged);
Linking.addEventListener('url', this.onDeepLink);
DeviceEventEmitter.addListener(Events.POST_DELETED_FOR_CHANNEL, this.onPostDeletedForChannel);
}
init = () => {
@ -53,6 +55,10 @@ class GlobalEventHandlerSingleton {
this.JavascriptAndNativeErrorHandler?.initializeErrorHandling();
};
onPostDeletedForChannel = async ({serverUrl, teamId}: {serverUrl: string; teamId: string}) => {
batchTeamThreadSync(serverUrl, teamId);
};
onDeepLink = async (event: LinkingCallbackArg) => {
if (event.url?.startsWith(Sso.REDIRECT_URL_SCHEME) || event.url?.startsWith(Sso.REDIRECT_URL_SCHEME_DEV)) {
return;

View file

@ -8,6 +8,7 @@ import {of as of$} from 'rxjs';
import {General, Permissions} from '@constants';
import {MM_TABLES} from '@constants/database';
import DatabaseManager from '@database/manager';
import ServerDataOperator from '@database/operator/server_data_operator';
import EphemeralStore from '@store/ephemeral_store';
import TestHelper from '@test/test_helper';
@ -63,6 +64,10 @@ import {prepareChannels,
queryChannelMembers,
queryChannelsForAutocomplete,
observeChannelMembers,
queryMyChannelsByChannelIds,
queryMyChannelsWithAutotranslation,
observeChannelAutotranslation,
observeIsChannelAutotranslated,
} from './channel';
import {queryRoles} from './role';
import {getCurrentChannelId, observeCurrentChannelId, observeCurrentUserId} from './system';
@ -74,7 +79,15 @@ import type ChannelInfoModel from '@typings/database/models/servers/channel_info
import type ChannelMembershipModel from '@typings/database/models/servers/channel_membership';
jest.mock('./role');
jest.mock('./system');
jest.mock('./system', () => {
const actual = jest.requireActual<typeof import('./system')>('./system');
return {
...actual,
getCurrentChannelId: jest.fn(),
observeCurrentChannelId: jest.fn(),
observeCurrentUserId: jest.fn(),
};
});
jest.mock('./user');
jest.mock('@utils/role');
@ -1580,3 +1593,323 @@ 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 () => {
await operator.handleConfigs({
configs: [
{id: 'EnableAutoTranslation', value: 'true'},
{id: 'RestrictDMAndGMAutotranslation', value: 'false'},
],
configsToDelete: [],
prepareRecordsOnly: 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(true);
});
it('should emit false when EnableAutoTranslation is false', async () => {
await operator.handleConfigs({
configs: [
{id: 'EnableAutoTranslation', value: 'false'},
{id: 'RestrictDMAndGMAutotranslation', value: 'false'},
],
configsToDelete: [],
prepareRecordsOnly: 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 () => {
await operator.handleConfigs({
configs: [
{id: 'EnableAutoTranslation', value: 'true'},
{id: 'RestrictDMAndGMAutotranslation', value: 'false'},
],
configsToDelete: [],
prepareRecordsOnly: false,
});
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 () => {
await operator.handleConfigs({
configs: [
{id: 'EnableAutoTranslation', value: 'true'},
{id: 'RestrictDMAndGMAutotranslation', value: 'false'},
],
configsToDelete: [],
prepareRecordsOnly: false,
});
const subscriptionNext = jest.fn();
const result = observeChannelAutotranslation(database, 'nonexistent');
result.subscribe({next: subscriptionNext});
expect(subscriptionNext).toHaveBeenCalledWith(false);
});
it('should emit false when channel is DM and RestrictDMAndGMAutotranslation is true', async () => {
await operator.handleConfigs({
configs: [
{id: 'EnableAutoTranslation', value: 'true'},
{id: 'RestrictDMAndGMAutotranslation', value: 'true'},
],
configsToDelete: [],
prepareRecordsOnly: false,
});
const channel = TestHelper.fakeChannel({
id: channelId,
type: General.DM_CHANNEL,
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 true when channel is DM and RestrictDMAndGMAutotranslation is false', async () => {
await operator.handleConfigs({
configs: [
{id: 'EnableAutoTranslation', value: 'true'},
{id: 'RestrictDMAndGMAutotranslation', value: 'false'},
],
configsToDelete: [],
prepareRecordsOnly: false,
});
const channel = TestHelper.fakeChannel({
id: channelId,
type: General.DM_CHANNEL,
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);
});
});
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 () => {
await operator.handleConfigs({
configs: [
{id: 'EnableAutoTranslation', value: 'true'},
{id: 'RestrictDMAndGMAutotranslation', value: 'false'},
],
configsToDelete: [],
prepareRecordsOnly: false,
});
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 () => {
await operator.handleConfigs({
configs: [
{id: 'EnableAutoTranslation', value: 'true'},
{id: 'RestrictDMAndGMAutotranslation', value: 'false'},
],
configsToDelete: [],
prepareRecordsOnly: false,
});
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 () => {
await operator.handleConfigs({
configs: [
{id: 'EnableAutoTranslation', value: 'true'},
{id: 'RestrictDMAndGMAutotranslation', value: 'false'},
],
configsToDelete: [],
prepareRecordsOnly: false,
});
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);
});
it('should emit false when channel is DM and RestrictDMAndGMAutotranslation is true', async () => {
await operator.handleConfigs({
configs: [
{id: 'EnableAutoTranslation', value: 'true'},
{id: 'RestrictDMAndGMAutotranslation', value: 'true'},
],
configsToDelete: [],
prepareRecordsOnly: false,
});
const channel = TestHelper.fakeChannel({
id: channelId,
type: General.DM_CHANNEL,
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(false);
});
it('should emit true when channel is DM and RestrictDMAndGMAutotranslation is false', async () => {
await operator.handleConfigs({
configs: [
{id: 'EnableAutoTranslation', value: 'true'},
{id: 'RestrictDMAndGMAutotranslation', value: 'false'},
],
configsToDelete: [],
prepareRecordsOnly: false,
});
const channel = TestHelper.fakeChannel({
id: channelId,
type: General.DM_CHANNEL,
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);
});
});
});

View file

@ -5,13 +5,13 @@
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';
import {sanitizeLikeString} from '@helpers/database';
import EphemeralStore from '@store/ephemeral_store';
import {isDefaultChannel} from '@utils/channel';
import {isDefaultChannel, isDMorGM} from '@utils/channel';
import {hasPermission} from '@utils/role';
import {isSystemAdmin} from '@utils/user';
@ -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,30 @@ export const observeMyChannelRoles = (database: Database, channelId: string) =>
);
};
export const observeChannelAutotranslation = (database: Database, channelId: string) => {
const enableAutoTranslation = observeConfigBooleanValue(database, 'EnableAutoTranslation');
const restrictDMAndGMAutotranslation = observeConfigBooleanValue(database, 'RestrictDMAndGMAutotranslation');
const channel = observeChannel(database, channelId);
return combineLatest([enableAutoTranslation, restrictDMAndGMAutotranslation, channel]).pipe(
map$(([et, r, c]) => {
if (!et) {
return false;
}
if (!c?.autotranslation) {
return false;
}
if (isDMorGM(c) && r) {
return false;
}
return true;
}),
distinctUntilChanged(),
);
};
export const getChannelById = async (database: Database, channelId: string) => {
try {
const channel = await database.get<ChannelModel>(CHANNEL).find(channelId);
@ -469,6 +493,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 +810,29 @@ 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 restrictDMAndGMAutotranslation = observeConfigBooleanValue(database, 'RestrictDMAndGMAutotranslation');
const channel = observeChannel(database, channelId);
const myChannel = observeMyChannel(database, channelId);
return combineLatest([enableAutoTranslation, restrictDMAndGMAutotranslation, channel, myChannel]).pipe(map(([et, r, c, mc]) => {
if (!et) {
return false;
}
if (!c?.autotranslation) {
return false;
}
if (mc?.autotranslationDisabled) {
return false;
}
if (isDMorGM(c) && r) {
return false;
}
return true;
}));
};

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {handleAutotranslationChanges} from '@actions/remote/entry/common';
import {MM_TABLES} from '@constants/database';
import DatabaseManager from '@database/manager';
@ -159,7 +160,7 @@ export async function truncateCrtRelatedTables(serverUrl: string): Promise<{erro
* @param {boolean} [args.isCRTEnabled] - Whether Collapsed Reply Threads are enabled
* @returns {Promise<Model[]>} Promise that resolves to an array of processed database models
*/
export async function processEntryModels({
export async function processEntryModels(serverUrl: string, {
operator,
teamData,
chData,
@ -167,6 +168,7 @@ export async function processEntryModels({
meData,
isCRTEnabled,
}: PrepareModelsArgs): Promise<Model[]> {
await handleAutotranslationChanges(serverUrl, meData, chData);
const modelPromises = await prepareEntryModels({operator, teamData, chData, prefData, meData, isCRTEnabled});
const flattenModels = (await Promise.all(modelPromises)).flat();

View file

@ -14,6 +14,7 @@ import {
observePermissionForPost,
observeCanManageChannelMembers,
observeCanManageChannelSettings,
observeCanManageChannelAutotranslations,
} from './role';
import type ServerDataOperator from '@database/operator/server_data_operator';
@ -427,4 +428,221 @@ describe('Role Queries', () => {
});
});
});
describe('observeCanManageChannelAutotranslations', () => {
it('should emit false when EnableAutoTranslation is false', async () => {
const mockUser = TestHelper.fakeUserModel({id: 'user1', roles: 'system_user'});
await Promise.all([
operator.handleConfigs({
configs: [
{id: 'EnableAutoTranslation', value: 'false'},
{id: 'RestrictDMAndGMAutotranslation', value: 'false'},
],
configsToDelete: [],
prepareRecordsOnly: false,
}),
operator.handleChannel({
channels: [TestHelper.fakeChannel({
id: 'channel1',
type: General.OPEN_CHANNEL,
delete_at: 0,
})],
prepareRecordsOnly: false,
}),
]);
const subscriptionNext = jest.fn();
const result = observeCanManageChannelAutotranslations(database, 'channel1', mockUser);
result.subscribe({next: subscriptionNext});
expect(subscriptionNext).toHaveBeenCalledWith(false);
});
it('should emit false when channel is not found', async () => {
const mockUser = TestHelper.fakeUserModel({id: 'user1', roles: 'system_user'});
await operator.handleConfigs({
configs: [
{id: 'EnableAutoTranslation', value: 'true'},
{id: 'RestrictDMAndGMAutotranslation', value: 'false'},
],
configsToDelete: [],
prepareRecordsOnly: false,
});
const subscriptionNext = jest.fn();
const result = observeCanManageChannelAutotranslations(database, 'nonexistent', mockUser);
result.subscribe({next: subscriptionNext});
expect(subscriptionNext).toHaveBeenCalledWith(false);
});
it('should emit false when channel is deleted', async () => {
const mockUser = TestHelper.fakeUserModel({id: 'user1', roles: 'system_user'});
await Promise.all([
operator.handleConfigs({
configs: [
{id: 'EnableAutoTranslation', value: 'true'},
{id: 'RestrictDMAndGMAutotranslation', value: 'false'},
],
configsToDelete: [],
prepareRecordsOnly: false,
}),
operator.handleChannel({
channels: [TestHelper.fakeChannel({
id: 'channel1',
type: General.OPEN_CHANNEL,
delete_at: 123,
})],
prepareRecordsOnly: false,
}),
]);
const subscriptionNext = jest.fn();
const result = observeCanManageChannelAutotranslations(database, 'channel1', mockUser);
result.subscribe({next: subscriptionNext});
expect(subscriptionNext).toHaveBeenCalledWith(false);
});
it('should emit false when channel is DM and RestrictDMAndGMAutotranslation is true', async () => {
const mockUser = TestHelper.fakeUserModel({id: 'user1', roles: 'system_user'});
await Promise.all([
operator.handleConfigs({
configs: [
{id: 'EnableAutoTranslation', value: 'true'},
{id: 'RestrictDMAndGMAutotranslation', value: 'true'},
],
configsToDelete: [],
prepareRecordsOnly: false,
}),
operator.handleChannel({
channels: [TestHelper.fakeChannel({
id: 'channel1',
type: General.DM_CHANNEL,
delete_at: 0,
})],
prepareRecordsOnly: false,
}),
]);
const subscriptionNext = jest.fn();
const result = observeCanManageChannelAutotranslations(database, 'channel1', mockUser);
result.subscribe({next: subscriptionNext});
expect(subscriptionNext).toHaveBeenCalledWith(false);
});
it('should emit true when channel is DM and RestrictDMAndGMAutotranslation is false', async () => {
const mockUser = TestHelper.fakeUserModel({id: 'user1', roles: 'system_user'});
await Promise.all([
operator.handleConfigs({
configs: [
{id: 'EnableAutoTranslation', value: 'true'},
{id: 'RestrictDMAndGMAutotranslation', value: 'false'},
],
configsToDelete: [],
prepareRecordsOnly: false,
}),
operator.handleChannel({
channels: [TestHelper.fakeChannel({
id: 'channel1',
type: General.DM_CHANNEL,
delete_at: 0,
})],
prepareRecordsOnly: false,
}),
]);
const subscriptionNext = jest.fn();
const result = observeCanManageChannelAutotranslations(database, 'channel1', mockUser);
result.subscribe({next: subscriptionNext});
expect(subscriptionNext).toHaveBeenCalledWith(true);
});
it('should emit true for open channel when user has MANAGE_PUBLIC_CHANNEL_AUTO_TRANSLATION', async () => {
const mockUser = TestHelper.fakeUserModel({
id: 'user1',
roles: 'channel_admin',
});
await Promise.all([
operator.handleConfigs({
configs: [
{id: 'EnableAutoTranslation', value: 'true'},
{id: 'RestrictDMAndGMAutotranslation', value: 'false'},
],
configsToDelete: [],
prepareRecordsOnly: false,
}),
operator.handleChannel({
channels: [TestHelper.fakeChannel({
id: 'channel1',
type: General.OPEN_CHANNEL,
delete_at: 0,
})],
prepareRecordsOnly: false,
}),
operator.handleRole({
roles: [{
id: 'channel_admin',
name: 'channel_admin',
permissions: [Permissions.MANAGE_PUBLIC_CHANNEL_AUTO_TRANSLATION],
}],
prepareRecordsOnly: false,
}),
]);
const subscriptionNext = jest.fn();
const result = observeCanManageChannelAutotranslations(database, 'channel1', mockUser);
result.subscribe({next: subscriptionNext});
expect(subscriptionNext).toHaveBeenCalledWith(true);
});
it('should emit true for private channel when user has MANAGE_PRIVATE_CHANNEL_AUTO_TRANSLATION', async () => {
const mockUser = TestHelper.fakeUserModel({
id: 'user1',
roles: 'channel_admin',
});
await Promise.all([
operator.handleConfigs({
configs: [
{id: 'EnableAutoTranslation', value: 'true'},
{id: 'RestrictDMAndGMAutotranslation', value: 'false'},
],
configsToDelete: [],
prepareRecordsOnly: false,
}),
operator.handleChannel({
channels: [TestHelper.fakeChannel({
id: 'channel1',
type: General.PRIVATE_CHANNEL,
delete_at: 0,
})],
prepareRecordsOnly: false,
}),
operator.handleRole({
roles: [{
id: 'channel_admin',
name: 'channel_admin',
permissions: [Permissions.MANAGE_PRIVATE_CHANNEL_AUTO_TRANSLATION],
}],
prepareRecordsOnly: false,
}),
]);
const subscriptionNext = jest.fn();
const result = observeCanManageChannelAutotranslations(database, 'channel1', mockUser);
result.subscribe({next: subscriptionNext});
expect(subscriptionNext).toHaveBeenCalledWith(true);
});
});
});

View file

@ -10,6 +10,7 @@ import {isDefaultChannel, isDMorGM} from '@utils/channel';
import {hasPermission} from '@utils/role';
import {observeChannel, observeMyChannelRoles} from './channel';
import {observeConfigBooleanValue} from './system';
import {observeMyTeam, observeMyTeamRoles} from './team';
import type ChannelModel from '@typings/database/models/servers/channel';
@ -115,3 +116,28 @@ export function observeCanManageChannelSettings(database: Database, channelId: s
distinctUntilChanged(),
);
}
export function observeCanManageChannelAutotranslations(database: Database, channelId: string, user: UserModel) {
const featureEnabled = observeConfigBooleanValue(database, 'EnableAutoTranslation');
const channel = observeChannel(database, channelId);
const restrictDMAndGMAutotranslation = observeConfigBooleanValue(database, 'RestrictDMAndGMAutotranslation');
return combineLatest([featureEnabled, channel, restrictDMAndGMAutotranslation]).pipe(
switchMap(([enabled, c, isDmGmRestricted]) => {
if (!enabled) {
return of$(false);
}
if (!c || c.deleteAt !== 0) {
return of$(false);
}
if (isDMorGM(c)) {
return of$(!isDmGmRestricted);
}
const permission = c.type === General.OPEN_CHANNEL ? Permissions.MANAGE_PUBLIC_CHANNEL_AUTO_TRANSLATION : Permissions.MANAGE_PRIVATE_CHANNEL_AUTO_TRANSLATION;
return observePermissionForChannel(database, c, user, permission, false);
}),
distinctUntilChanged(),
);
}

View file

@ -150,3 +150,20 @@ export const getUsersFromDMSorted = async (database: Database, memberIds: string
return [];
}
};
export const observeIsUserLanguageSupportedByAutotranslation = (database: Database) => {
const currentUser = observeCurrentUser(database);
const autoTranslationLanguages = observeConfigValue(database, 'AutoTranslationLanguages');
return combineLatest([currentUser, autoTranslationLanguages]).pipe(
switchMap(([user, languages]) => {
if (!user?.locale || !languages) {
return of$(false);
}
const userLocale = user.locale;
const languagesList = languages.split(',');
return of$(languagesList.includes(userLocale));
}),
distinctUntilChanged(),
);
};

View file

@ -1,14 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {Platform, type LayoutChangeEvent, StyleSheet} from 'react-native';
import React, {useCallback, useEffect, useState, useMemo} from 'react';
import {Platform, DeviceEventEmitter, type LayoutChangeEvent, StyleSheet} from 'react-native';
import {KeyboardProvider} from 'react-native-keyboard-controller';
import {type Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
import {storeLastViewedChannelIdAndServer, removeLastViewedChannelIdAndServer} from '@actions/app/global';
import {fetchPostsForChannel} from '@actions/remote/post';
import FloatingCallContainer from '@calls/components/floating_call_container';
import FreezeScreen from '@components/freeze_screen';
import {Events} from '@constants';
import {useServerUrl} from '@context/server';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useChannelSwitch} from '@hooks/channel_switch';
import {useIsTablet} from '@hooks/device';
@ -75,6 +78,7 @@ const Channel = ({
const switchingChannels = useChannelSwitch();
const defaultHeight = useDefaultHeaderHeight();
const [containerHeight, setContainerHeight] = useState(0);
const serverUrl = useServerUrl();
const shouldRender = !switchingTeam && !switchingChannels && shouldRenderPosts && Boolean(channelId);
const isVisible = useIsScreenVisible(componentId);
const [isEmojiSearchFocused, setIsEmojiSearchFocused] = useState(false);
@ -95,6 +99,15 @@ const Channel = ({
useAndroidHardwareBackHandler(componentId, handleBack);
useEffect(() => {
const listener = DeviceEventEmitter.addListener(Events.POST_DELETED_FOR_CHANNEL, ({serverUrl: url, channelId: id}) => {
if (serverUrl === url && channelId === id) {
fetchPostsForChannel(serverUrl, channelId, false, true);
}
});
return () => listener.remove();
}, [serverUrl, channelId]);
const marginTop = defaultHeight + (isTablet ? 0 : -insets.top);
useEffect(() => {
// This is done so that the header renders

View file

@ -27,8 +27,8 @@ const CLOSE_BUTTON_ID = 'channel-banner-close';
const getStyleSheet = (bannerTextColor: string) => ({
container: {
paddingTop: 5,
paddingBottom: 5,
paddingTop: 2,
paddingBottom: 2,
paddingLeft: 16,
paddingRight: 16,
height: CHANNEL_BANNER_HEIGHT,
@ -51,7 +51,6 @@ const getStyleSheet = (bannerTextColor: string) => ({
},
bannerText: {
textAlign: 'center' as const,
},
});

View file

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

View file

@ -60,6 +60,7 @@ type ChannelProps = {
playbooksActiveRuns: number;
isPlaybooksEnabled: boolean;
activeRunId?: string;
isChannelAutotranslated: boolean;
// searchTerm: string;
};
@ -113,6 +114,7 @@ const ChannelHeader = ({
hasPlaybookRuns,
isPlaybooksEnabled,
activeRunId,
isChannelAutotranslated,
}: ChannelProps) => {
const intl = useIntl();
const isTablet = useIsTablet();
@ -315,6 +317,19 @@ const ChannelHeader = ({
return undefined;
}, [memberCount, customStatus, isCustomStatusExpired, theme.sidebarHeaderTextColor, styles.customStatusContainer, styles.customStatusEmoji, styles.customStatusText, styles.subtitle, isCustomStatusEnabled]);
const titleCompanion = useMemo(() => {
if (isChannelAutotranslated) {
return (
<CompassIcon
name='translate'
size={16}
color={changeOpacity(theme.sidebarHeaderTextColor, 0.72)}
/>
);
}
return undefined;
}, [isChannelAutotranslated, theme.sidebarHeaderTextColor]);
useEffect(() => {
const asyncEffect = async () => {
if (isPlaybooksEnabled && !EphemeralStore.getChannelPlaybooksSynced(serverUrl, channelId)) {
@ -338,6 +353,7 @@ const ChannelHeader = ({
subtitle={subtitle}
subtitleCompanion={subtitleCompanion}
title={title}
titleCompanion={titleCompanion}
/>
<View style={contextStyle}>
<RoundedHeaderContext/>

View file

@ -9,10 +9,10 @@ import {combineLatestWith, distinctUntilChanged, switchMap} from 'rxjs/operators
import {General} from '@constants';
import {queryPlaybookRunsPerChannel} from '@playbooks/database/queries/run';
import {observeIsPlaybooksEnabled} from '@playbooks/database/queries/version';
import {observeChannel, observeChannelInfo} from '@queries/servers/channel';
import {observeChannel, observeChannelInfo, observeIsChannelAutotranslated} from '@queries/servers/channel';
import {observeCanAddBookmarks, queryBookmarks} from '@queries/servers/channel_bookmark';
import {observeConfigBooleanValue, observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system';
import {observeUser} from '@queries/servers/user';
import {observeIsUserLanguageSupportedByAutotranslation, observeUser} from '@queries/servers/user';
import {
getUserCustomStatus,
getUserIdFromChannelName,
@ -63,6 +63,14 @@ const enhanced = withObservables(['channelId'], ({channelId, database}: OwnProps
const isCustomStatusEnabled = observeConfigBooleanValue(database, 'EnableCustomUserStatuses');
const isPlaybooksEnabled = observeIsPlaybooksEnabled(database);
const isChannelAutotranslatedBase = observeIsChannelAutotranslated(database, channelId);
const isUserLanguageSupported = observeIsUserLanguageSupportedByAutotranslation(database);
const isChannelAutotranslated = isChannelAutotranslatedBase.pipe(
combineLatestWith(isUserLanguageSupported),
switchMap(([isAutotranslated, isLanguageSupported]) => of$(isAutotranslated && isLanguageSupported)),
distinctUntilChanged(),
);
// const searchTerm = channel.pipe(
// combineLatestWith(dmUser),
@ -116,6 +124,7 @@ const enhanced = withObservables(['channelId'], ({channelId, database}: OwnProps
displayName,
hasBookmarks,
isBookmarksEnabled,
isChannelAutotranslated,
isCustomStatusEnabled,
isCustomStatusExpired,
isOwnDirectMessage,

View file

@ -5,9 +5,7 @@ import React, {useCallback} from 'react';
import {ScrollView, View} from 'react-native';
import {type Edge, SafeAreaView} from 'react-native-safe-area-context';
import ChannelInfoEnableCalls from '@calls/components/channel_info_enable_calls';
import ChannelActions from '@components/channel_actions';
import ConvertToChannelLabel from '@components/channel_actions/convert_to_channel/convert_to_channel_label';
import ChannelBookmarks from '@components/channel_bookmarks';
import {General} from '@constants';
import {useServerUrl} from '@context/server';
@ -28,8 +26,6 @@ import type {AvailableScreens} from '@typings/screens/navigation';
type Props = {
canAddBookmarks: boolean;
canEnableDisableCalls: boolean;
canManageSettings: boolean;
channelId: string;
closeButtonId: string;
componentId: AvailableScreens;
@ -38,10 +34,10 @@ type Props = {
isPlaybooksEnabled: boolean;
groupCallsAllowed: boolean;
canManageMembers: boolean;
isConvertGMFeatureAvailable: boolean;
isCRTEnabled: boolean;
isGuestUser: boolean;
type?: ChannelType;
hasChannelSettingsActions: boolean;
isAutotranslationEnabledForThisChannel: boolean;
}
const edges: Edge[] = ['bottom', 'left', 'right'];
@ -63,9 +59,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
const ChannelInfo = ({
canAddBookmarks,
canEnableDisableCalls,
canManageMembers,
canManageSettings,
channelId,
closeButtonId,
componentId,
@ -73,10 +67,10 @@ const ChannelInfo = ({
isCallsEnabledInChannel,
isPlaybooksEnabled,
groupCallsAllowed,
isConvertGMFeatureAvailable,
isCRTEnabled,
isGuestUser,
type,
hasChannelSettingsActions,
isAutotranslationEnabledForThisChannel,
}: Props) => {
const theme = useTheme();
const serverUrl = useServerUrl();
@ -96,8 +90,6 @@ const ChannelInfo = ({
useNavButtonPressed(closeButtonId, componentId, onPressed, [onPressed]);
useAndroidHardwareBackHandler(componentId, onPressed);
const convertGMOptionAvailable = isConvertGMFeatureAvailable && type === General.GM_CHANNEL && !isGuestUser;
return (
<View
style={styles.flex}
@ -137,28 +129,14 @@ const ChannelInfo = ({
<Options
channelId={channelId}
type={type}
callsEnabled={callsAvailable}
callsEnabled={isCallsEnabledInChannel}
canManageMembers={canManageMembers}
isCRTEnabled={isCRTEnabled}
canManageSettings={canManageSettings}
isPlaybooksEnabled={isPlaybooksEnabled}
hasChannelSettingsActions={hasChannelSettingsActions}
isAutotranslationEnabledForThisChannel={isAutotranslationEnabledForThisChannel}
/>
<View style={styles.separator}/>
{convertGMOptionAvailable &&
<>
<ConvertToChannelLabel channelId={channelId}/>
<View style={styles.separator}/>
</>
}
{canEnableDisableCalls &&
<>
<ChannelInfoEnableCalls
channelId={channelId}
enabled={isCallsEnabledInChannel}
/>
<View style={styles.separator}/>
</>
}
<ChannelInfoAppBindings
channelId={channelId}
serverUrl={serverUrl}
@ -166,8 +144,6 @@ const ChannelInfo = ({
/>
<DestructiveOptions
channelId={channelId}
componentId={componentId}
type={type}
/>
</ScrollView>
</SafeAreaView>

View file

@ -1,79 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {of as of$} from 'rxjs';
import {combineLatestWith, switchMap} from 'rxjs/operators';
import {General, Permissions} from '@constants';
import {observeChannel} from '@queries/servers/channel';
import {observePermissionForChannel, observePermissionForTeam} from '@queries/servers/role';
import {observeConfigBooleanValue} from '@queries/servers/system';
import {observeCurrentTeam} from '@queries/servers/team';
import {observeCurrentUser} from '@queries/servers/user';
import {isDefaultChannel} from '@utils/channel';
import Archive from './archive';
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = WithDatabaseArgs & {
channelId: string;
type?: string;
}
const enhanced = withObservables(['channelId', 'type'], ({channelId, database, type}: Props) => {
const team = observeCurrentTeam(database);
const currentUser = observeCurrentUser(database);
const channel = observeChannel(database, channelId);
const canViewArchivedChannels = observeConfigBooleanValue(database, 'ExperimentalViewArchivedChannels');
const isArchived = channel.pipe(switchMap((c) => of$((c?.deleteAt || 0) > 0)));
const canLeave = channel.pipe(
combineLatestWith(currentUser),
switchMap(([ch, u]) => {
const isDC = isDefaultChannel(ch);
return of$(!isDC || (isDC && u?.isGuest));
}),
);
const canArchive = channel.pipe(
combineLatestWith(currentUser, canLeave, isArchived),
switchMap(([ch, u, leave, archived]) => {
if (
type === General.DM_CHANNEL || type === General.GM_CHANNEL ||
!ch || !u || !leave || archived
) {
return of$(false);
}
if (type === General.OPEN_CHANNEL) {
return observePermissionForChannel(database, ch, u, Permissions.DELETE_PUBLIC_CHANNEL, true);
}
return observePermissionForChannel(database, ch, u, Permissions.DELETE_PRIVATE_CHANNEL, true);
}),
);
const canUnarchive = team.pipe(
combineLatestWith(currentUser, isArchived),
switchMap(([t, u, archived]) => {
if (
type === General.DM_CHANNEL || type === General.GM_CHANNEL ||
!t || !u || !archived
) {
return of$(false);
}
return observePermissionForTeam(database, t, u, Permissions.MANAGE_TEAM, false);
}),
);
return {
canArchive,
canUnarchive,
canViewArchivedChannels,
displayName: channel.pipe(switchMap((c) => of$(c?.displayName))),
};
});
export default withDatabase(enhanced(Archive));

View file

@ -1,42 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {of as of$} from 'rxjs';
import {combineLatestWith, switchMap} from 'rxjs/operators';
import {Permissions} from '@constants';
import {observeChannel} from '@queries/servers/channel';
import {observePermissionForChannel} from '@queries/servers/role';
import {observeCurrentUser} from '@queries/servers/user';
import {isDefaultChannel} from '@utils/channel';
import ConvertPrivate from './convert_private';
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = WithDatabaseArgs & {
channelId: string;
}
const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => {
const currentUser = observeCurrentUser(database);
const channel = observeChannel(database, channelId);
const canConvert = channel.pipe(
combineLatestWith(currentUser),
switchMap(([ch, u]) => {
if (!ch || !u || isDefaultChannel(ch)) {
return of$(false);
}
return observePermissionForChannel(database, ch, u, Permissions.CONVERT_PUBLIC_CHANNEL_TO_PRIVATE, false);
}),
);
return {
canConvert,
displayName: channel.pipe(switchMap((c) => of$(c?.displayName))),
};
});
export default withDatabase(enhanced(ConvertPrivate));

View file

@ -4,37 +4,19 @@
import React from 'react';
import LeaveChannelLabel from '@components/channel_actions/leave_channel_label';
import {General} from '@constants';
import Archive from './archive';
import ConvertPrivate from './convert_private';
import type {AvailableScreens} from '@typings/screens/navigation';
type Props = {
channelId: string;
componentId: AvailableScreens;
type?: ChannelType;
}
const DestructiveOptions = ({channelId, componentId, type}: Props) => {
const DestructiveOptions = ({channelId}: Props) => {
return (
<>
{type === General.OPEN_CHANNEL &&
<ConvertPrivate channelId={channelId}/>
}
<LeaveChannelLabel
channelId={channelId}
isOptionItem={true}
testID='channel_info.options.leave_channel.option'
/>
{type !== General.DM_CHANNEL && type !== General.GM_CHANNEL &&
<Archive
channelId={channelId}
componentId={componentId}
type={type}
/>
}
</>
);
};

View file

@ -2,16 +2,17 @@
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {combineLatest, of as of$} from 'rxjs';
import {combineLatest, Observable, of as of$} from 'rxjs';
import {distinctUntilChanged, switchMap, combineLatestWith} from 'rxjs/operators';
import {observeIsCallsEnabledInChannel} from '@calls/observers';
import {observeCallsConfig} from '@calls/state';
import {General, Permissions} from '@constants';
import {withServerUrl} from '@context/server';
import {observeIsPlaybooksEnabled} from '@playbooks/database/queries/version';
import {observeCurrentChannel} from '@queries/servers/channel';
import {observeChannelAutotranslation, observeCurrentChannel} from '@queries/servers/channel';
import {observeCanAddBookmarks} from '@queries/servers/channel_bookmark';
import {observeCanManageChannelMembers, observeCanManageChannelSettings} from '@queries/servers/role';
import {observeCanManageChannelAutotranslations, 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,110 @@ const enhanced = withObservables([], ({serverUrl, database}: Props) => {
switchMap((version) => of$(isMinimumServerVersion(version || '', 9, 1))),
);
const canManageChannelAutotranslations = combineLatest([channelId, currentUser]).pipe(
switchMap(([cId, u]) => (u ? observeCanManageChannelAutotranslations(database, cId, u) : of$(false))),
distinctUntilChanged(),
);
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,
canManageChannelAutotranslations,
]).pipe(
switchMap(([manageSettings, convert, archive, unarchive, enableCalls, convertGM, manageAutotranslations]) => {
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
manageAutotranslations, // 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 +249,21 @@ const enhanced = withObservables([], ({serverUrl, database}: Props) => {
}),
);
const isPlaybooksEnabled = observeIsPlaybooksEnabled(database);
const isAutotranslationEnabledForThisChannel = channelId.pipe(
switchMap((cId) => observeChannelAutotranslation(database, cId)),
);
const isPlaybooksEnabled = observeIsPlaybooksEnabled(database);
return {
type,
canEnableDisableCalls,
isCallsEnabledInChannel,
groupCallsAllowed,
canAddBookmarks,
canManageMembers,
canManageSettings,
isBookmarksEnabled,
isCRTEnabled: observeIsCRTEnabled(database),
isGuestUser,
isConvertGMFeatureAvailable,
isPlaybooksEnabled,
hasChannelSettingsActions: observeHasChannelSettingsActions(database, serverUrl, channelId, channel, currentUser, type),
isAutotranslationEnabledForThisChannel,
};
});

View file

@ -0,0 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Platform} from 'react-native';
import OptionItem from '@components/option_item';
import {Screens} from '@constants';
import {usePreventDoubleTap} from '@hooks/utils';
import {goToScreen} from '@screens/navigation';
type Props = {
channelId: string;
}
const ChannelSettings = ({channelId}: Props) => {
const {formatMessage} = useIntl();
const title = formatMessage({id: 'channel_info.channel_settings', defaultMessage: 'Channel Settings'});
const goToChannelSettings = usePreventDoubleTap(useCallback(async () => {
goToScreen(Screens.CHANNEL_SETTINGS, title, {channelId});
}, [channelId, title]));
return (
<OptionItem
action={goToChannelSettings}
label={title}
icon='settings-outline'
type={Platform.select({ios: 'arrow', default: 'default'})}
testID='channel_info.options.channel_settings.option'
/>
);
};
export default ChannelSettings;

View file

@ -8,6 +8,8 @@ import DatabaseManager from '@database/manager';
import PlaybookRunsOption from '@playbooks/components/channel_actions/playbook_runs_option';
import {renderWithEverything} from '@test/intl-test-helper';
import MyAutotranslation from './my_autotranslation';
import ChannelInfoOptions from './';
import type {Database} from '@nozbe/watermelondb';
@ -19,6 +21,11 @@ jest.mocked(PlaybookRunsOption).mockImplementation((props) => {
return React.createElement('PlaybookRunsOption', {...props, testID: 'playbook-runs-option'});
});
jest.mock('./my_autotranslation');
jest.mocked(MyAutotranslation).mockImplementation((props) => {
return React.createElement('MyAutotranslation', {...props, testID: 'my-autotranslation-option'});
});
describe('ChannelInfoOptions', () => {
let database: Database;
@ -28,8 +35,9 @@ describe('ChannelInfoOptions', () => {
callsEnabled: false,
canManageMembers: false,
isCRTEnabled: false,
canManageSettings: false,
isPlaybooksEnabled: true,
hasChannelSettingsActions: false,
isAutotranslationEnabledForThisChannel: true,
};
}
beforeEach(async () => {
@ -57,4 +65,16 @@ describe('ChannelInfoOptions', () => {
rerender(<ChannelInfoOptions {...props}/>);
expect(queryByTestId('playbook-runs-option')).toBeNull();
});
it('should not show my autotranslation option when isAutotranslationEnabledForThisChannel is false', () => {
const props = getBaseProps();
props.isAutotranslationEnabledForThisChannel = false;
const {queryByTestId} = renderWithEverything(<ChannelInfoOptions {...props}/>, {database});
expect(queryByTestId('my-autotranslation-option')).toBeNull();
});
it('should show my autotranslation option when isAutotranslationEnabledForThisChannel is true', () => {
const props = getBaseProps();
props.isAutotranslationEnabledForThisChannel = true;
const {getByTestId} = renderWithEverything(<ChannelInfoOptions {...props}/>, {database});
expect(getByTestId('my-autotranslation-option')).toBeTruthy();
});
});

View file

@ -11,9 +11,10 @@ import {isTypeDMorGM} from '@utils/channel';
import AddMembers from './add_members';
import AutoFollowThreads from './auto_follow_threads';
import ChannelFiles from './channel_files';
import EditChannel from './edit_channel';
import ChannelSettings from './channel_settings';
import IgnoreMentions from './ignore_mentions';
import Members from './members';
import MyAutotranslation from './my_autotranslation';
import NotificationPreference from './notification_preference';
import PinnedMessages from './pinned_messages';
@ -24,7 +25,8 @@ type Props = {
canManageMembers: boolean;
isCRTEnabled: boolean;
isPlaybooksEnabled: boolean;
canManageSettings: boolean;
hasChannelSettingsActions: boolean;
isAutotranslationEnabledForThisChannel: boolean;
}
const Options = ({
@ -34,12 +36,16 @@ const Options = ({
canManageMembers,
isCRTEnabled,
isPlaybooksEnabled,
canManageSettings,
hasChannelSettingsActions,
isAutotranslationEnabledForThisChannel,
}: Props) => {
const isDMorGM = isTypeDMorGM(type);
return (
<>
{hasChannelSettingsActions && (
<ChannelSettings channelId={channelId}/>
)}
{type !== General.DM_CHANNEL && (
<>
{isCRTEnabled && (
@ -49,6 +55,9 @@ const Options = ({
</>
)}
<NotificationPreference channelId={channelId}/>
{isAutotranslationEnabledForThisChannel && (
<MyAutotranslation channelId={channelId}/>
)}
<PinnedMessages channelId={channelId}/>
<ChannelFiles channelId={channelId}/>
{isPlaybooksEnabled && !isDMorGM &&
@ -69,9 +78,6 @@ const Options = ({
testID='channel_info.options.copy_channel_link.option'
/>
}
{canManageSettings &&
<EditChannel channelId={channelId}/>
}
</>
);
};

View file

@ -0,0 +1,32 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeChannel, observeIsChannelAutotranslated} from '@queries/servers/channel';
import {observeIsUserLanguageSupportedByAutotranslation} from '@queries/servers/user';
import MyAutotranslation from './my_autotranslation';
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = WithDatabaseArgs & {
channelId: string;
}
const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => {
const channel = observeChannel(database, channelId);
const enabled = observeIsChannelAutotranslated(database, channelId);
const isLanguageSupported = observeIsUserLanguageSupportedByAutotranslation(database);
return {
enabled,
displayName: channel.pipe(switchMap((c) => of$(c?.displayName || ''))),
isLanguageSupported,
};
});
export default withDatabase(enhanced(MyAutotranslation));

View file

@ -0,0 +1,132 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Alert} from 'react-native';
import {setMyChannelAutotranslation} from '@actions/remote/channel';
import DatabaseManager from '@database/manager';
import {act, fireEvent, renderWithEverything, waitFor} from '@test/intl-test-helper';
import MyAutotranslation from './my_autotranslation';
const serverUrl = 'my_autotranslation.test.com';
jest.mock('@context/server', () => ({
useServerUrl: jest.fn().mockReturnValue(serverUrl),
}));
jest.mock('@actions/remote/channel', () => ({
setMyChannelAutotranslation: jest.fn(),
}));
jest.mock('@hooks/utils', () => ({
usePreventDoubleTap: (fn: () => void) => fn,
}));
jest.mock('react-native/Libraries/Alert/Alert', () => ({
alert: jest.fn(),
}));
describe('MyAutotranslation', () => {
let database: import('@nozbe/watermelondb').Database;
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
jest.clearAllMocks();
});
it('renders disabled option when language is not supported', () => {
const {getByTestId, getByText} = renderWithEverything(
<MyAutotranslation
channelId='channel1'
enabled={false}
displayName='Test Channel'
isLanguageSupported={false}
/>,
{database},
);
expect(getByTestId('channel_info.options.my_autotranslation.option')).toBeTruthy();
expect(getByText('Your language is not supported')).toBeTruthy();
});
it('renders toggle with correct testID when enabled', () => {
const {getByTestId} = renderWithEverything(
<MyAutotranslation
channelId='channel1'
enabled={true}
displayName='Test Channel'
isLanguageSupported={true}
/>,
{database},
);
expect(getByTestId('channel_info.options.my_autotranslation.option.toggled.true')).toBeTruthy();
});
it('renders toggle with correct testID when disabled', () => {
const {getByTestId} = renderWithEverything(
<MyAutotranslation
channelId='channel1'
enabled={false}
displayName='Test Channel'
isLanguageSupported={true}
/>,
{database},
);
expect(getByTestId('channel_info.options.my_autotranslation.option.toggled.false')).toBeTruthy();
});
it('calls setMyChannelAutotranslation when toggling from off to on', async () => {
jest.mocked(setMyChannelAutotranslation).mockResolvedValue({data: true});
const {getByTestId} = renderWithEverything(
<MyAutotranslation
channelId='channel1'
enabled={false}
displayName='Test Channel'
isLanguageSupported={true}
/>,
{database},
);
const toggle = getByTestId('channel_info.options.my_autotranslation.option.toggled.false.toggled.false.button');
await act(async () => {
fireEvent(toggle, 'valueChange', true);
});
await waitFor(() => {
expect(setMyChannelAutotranslation).toHaveBeenCalledWith(serverUrl, 'channel1', true);
});
});
it('calls alert when toggling from on to off', async () => {
jest.mocked(setMyChannelAutotranslation).mockResolvedValue({data: false});
const {getByTestId} = renderWithEverything(
<MyAutotranslation
channelId='channel1'
enabled={true}
displayName='Test Channel'
isLanguageSupported={true}
/>,
{database},
);
const toggle = getByTestId('channel_info.options.my_autotranslation.option.toggled.true.toggled.true.button');
await act(async () => {
fireEvent(toggle, 'valueChange', false);
});
await waitFor(() => {
expect(Alert.alert).toHaveBeenCalledWith('Turn off auto-translation', 'Messages in this channel will revert to their original language. This will only affect how you see this channel. Other members wont be affected.', [
{text: 'cancel', style: 'cancel'},
{text: 'Yes, turn off', onPress: expect.any(Function)},
]);
expect(setMyChannelAutotranslation).not.toHaveBeenCalled();
});
const yesButton = jest.mocked(Alert.alert).mock.calls[0][2]?.[1];
await act(async () => {
yesButton?.onPress?.();
});
await waitFor(() => {
expect(setMyChannelAutotranslation).toHaveBeenCalledWith(serverUrl, 'channel1', false);
});
});
});

View file

@ -0,0 +1,126 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import {defineMessages, useIntl} from 'react-intl';
import {Alert} from 'react-native';
import {setMyChannelAutotranslation} from '@actions/remote/channel';
import OptionItem from '@components/option_item';
import {useServerUrl} from '@context/server';
import {usePreventDoubleTap} from '@hooks/utils';
import {alertErrorWithFallback} from '@utils/draft';
const messages = defineMessages({
label: {
id: 'channel_info.my_autotranslation',
defaultMessage: 'Auto-translation',
},
failed: {
id: 'channel_info.my_autotranslation_failed',
defaultMessage: 'An error occurred trying to enable automatic translation for yourself in channel {displayName}',
},
languageNotSupported: {
id: 'channel_info.my_autotranslation_language_not_supported',
defaultMessage: 'Your language is not supported',
},
enabled: {
id: 'channel_info.my_autotranslation_enabled',
defaultMessage: 'On ({language})',
},
disabled: {
id: 'channel_info.my_autotranslation_disabled',
defaultMessage: 'Off',
},
turnOffTitle: {
id: 'channel_info.turn_off_auto_translation.title',
defaultMessage: 'Turn off auto-translation',
},
turnOffDescription: {
id: 'channel_info.turn_off_auto_translation.description',
defaultMessage: "Messages in this channel will revert to their original language. This will only affect how you see this channel. Other members won't be affected.",
},
turnOffCancel: {
id: 'channel_info.turn_off_auto_translation.button.cancel',
defaultMessage: 'cancel',
},
turnOffYes: {
id: 'channel_info.turn_off_auto_translation.button.yes',
defaultMessage: 'Yes, turn off',
},
});
type Props = {
channelId: string;
enabled: boolean;
displayName: string;
isLanguageSupported: boolean;
};
const MyAutotranslation = ({channelId, displayName, enabled, isLanguageSupported}: Props) => {
// Use the local state for optimistic updates
const [autotranslation, setAutotranslation] = useState(enabled);
const serverUrl = useServerUrl();
const intl = useIntl();
const doToggleAutotranslation = useCallback(async () => {
setAutotranslation((v) => !v);
const result = await setMyChannelAutotranslation(serverUrl, channelId, !enabled);
if (result?.error) {
alertErrorWithFallback(
intl,
result.error,
messages.failed,
{displayName},
);
setAutotranslation((v) => !v);
}
}, [channelId, displayName, enabled, intl, serverUrl]);
const toggleAutotranslation = usePreventDoubleTap(useCallback(async () => {
if (autotranslation) {
Alert.alert(
intl.formatMessage(messages.turnOffTitle),
intl.formatMessage(messages.turnOffDescription),
[
{text: intl.formatMessage(messages.turnOffCancel), style: 'cancel'},
{text: intl.formatMessage(messages.turnOffYes), onPress: () => doToggleAutotranslation()},
],
);
} else {
doToggleAutotranslation();
}
}, [autotranslation, doToggleAutotranslation, intl]));
if (!isLanguageSupported) {
return (
<OptionItem
label={intl.formatMessage(messages.label)}
description={intl.formatMessage(messages.languageNotSupported)}
icon='translate'
type='none'
disabled={true}
testID='channel_info.options.my_autotranslation.option'
/>
);
}
const description = autotranslation ? intl.formatMessage(messages.enabled, {
language: intl.formatDisplayName(intl.locale, {type: 'language'}),
}) : intl.formatMessage(messages.disabled);
return (
<OptionItem
action={toggleAutotranslation}
label={intl.formatMessage(messages.label)}
description={description}
icon='translate'
type='toggle'
selected={autotranslation}
testID={`channel_info.options.my_autotranslation.option.toggled.${autotranslation}`}
/>
);
};
export default MyAutotranslation;

View file

@ -159,7 +159,7 @@ const Archive = ({
icon='archive-arrow-up-outline'
destructive={true}
type='default'
testID='channel_info.options.unarchive_channel.option'
testID='channel_settings.unarchive_channel.option'
/>
);
}
@ -171,9 +171,10 @@ const Archive = ({
icon='archive-outline'
destructive={true}
type='default'
testID='channel_info.options.archive_channel.option'
testID='channel_settings.archive_channel.option'
/>
);
};
export default Archive;

View file

@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeChannel} from '@queries/servers/channel';
import {observeConfigBooleanValue} from '@queries/servers/system';
import Archive from './archive';
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = WithDatabaseArgs & {
channelId: string;
}
const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => {
const channel = observeChannel(database, channelId);
const canViewArchivedChannels = observeConfigBooleanValue(database, 'ExperimentalViewArchivedChannels');
return {
canViewArchivedChannels,
displayName: channel.pipe(switchMap((c) => of$(c?.displayName))),
};
});
export default withDatabase(enhanced(Archive));

View file

@ -0,0 +1,97 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Alert} from 'react-native';
import {setChannelAutotranslation} from '@actions/remote/channel';
import DatabaseManager from '@database/manager';
import {fireEvent, renderWithEverything, waitFor} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import ChannelAutotranslation from './channel_autotranslation';
const serverUrl = 'channel_autotranslation.test.com';
jest.mock('@context/server', () => ({
useServerUrl: jest.fn().mockReturnValue(serverUrl),
}));
jest.mock('@actions/remote/channel', () => ({
setChannelAutotranslation: jest.fn(),
}));
jest.mock('@hooks/utils', () => ({
usePreventDoubleTap: (fn: () => void) => fn,
}));
describe('ChannelAutotranslation', () => {
let database: import('@nozbe/watermelondb').Database;
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
jest.clearAllMocks();
});
it('renders toggle with correct testID when enabled', () => {
const {getByTestId} = renderWithEverything(
<ChannelAutotranslation
channelId='channel1'
enabled={true}
displayName='Test Channel'
/>,
{database},
);
expect(getByTestId('channel_settings.channel_autotranslation.option.toggled.true')).toBeTruthy();
});
it('renders toggle with correct testID when disabled', () => {
const {getByTestId} = renderWithEverything(
<ChannelAutotranslation
channelId='channel1'
enabled={false}
displayName='Test Channel'
/>,
{database},
);
expect(getByTestId('channel_settings.channel_autotranslation.option.toggled.false')).toBeTruthy();
});
it('calls setChannelAutotranslation when toggling from off to on', async () => {
jest.mocked(setChannelAutotranslation).mockResolvedValue({channel: TestHelper.fakeChannel({id: 'channel1'})});
const {getByTestId} = renderWithEverything(
<ChannelAutotranslation
channelId='channel1'
enabled={false}
displayName='Test Channel'
/>,
{database},
);
const toggle = getByTestId('channel_settings.channel_autotranslation.option.toggled.false.toggled.false.button');
fireEvent(toggle, 'valueChange', true);
await waitFor(() => {
expect(setChannelAutotranslation).toHaveBeenCalledWith(serverUrl, 'channel1', true);
});
});
it('calls alert when setting autotranslation fails', async () => {
jest.mocked(setChannelAutotranslation).mockResolvedValue({error: 'error'});
const {getByTestId} = renderWithEverything(
<ChannelAutotranslation
channelId='channel1'
enabled={true}
displayName='Test Channel'
/>,
{database},
);
const toggle = getByTestId('channel_settings.channel_autotranslation.option.toggled.true.toggled.true.button');
fireEvent(toggle, 'valueChange', false);
await waitFor(() => {
expect(Alert.alert).toHaveBeenCalledWith('', 'An error occurred trying to enable automatic translation for channel Test Channel', undefined);
expect(getByTestId('channel_settings.channel_autotranslation.option.toggled.true.toggled.true.button')).toBeTruthy();
});
});
});

View file

@ -0,0 +1,61 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import {defineMessage, useIntl} from 'react-intl';
import {setChannelAutotranslation} from '@actions/remote/channel';
import OptionItem from '@components/option_item';
import {useServerUrl} from '@context/server';
import {usePreventDoubleTap} from '@hooks/utils';
import {alertErrorWithFallback} from '@utils/draft';
type Props = {
channelId: string;
enabled: boolean;
displayName: string;
}
const ChannelAutotranslation = ({channelId, displayName, enabled}: Props) => {
const [autotranslation, setAutotranslation] = useState(enabled);
const serverUrl = useServerUrl();
const intl = useIntl();
const toggleAutotranslation = usePreventDoubleTap(useCallback(async () => {
setAutotranslation((v) => !v);
const result = await setChannelAutotranslation(serverUrl, channelId, !enabled);
if (result?.error) {
alertErrorWithFallback(
intl,
result.error,
defineMessage({
id: 'channel_settings.channel_autotranslation_failed',
defaultMessage: 'An error occurred trying to enable automatic translation for channel {displayName}',
}),
{displayName},
);
setAutotranslation((v) => !v);
}
}, [channelId, displayName, enabled, intl, serverUrl]));
return (
<OptionItem
action={toggleAutotranslation}
label={intl.formatMessage({
id: 'channel_settings.channel_autotranslation',
defaultMessage: 'Auto-translation',
})}
description={intl.formatMessage({
id: 'channel_settings.channel_autotranslation_description',
defaultMessage: 'When enabled, channel members can turn on auto-translation to view messages in their preferred language.',
})}
icon='translate'
type='toggle'
selected={autotranslation}
testID={`channel_settings.channel_autotranslation.option.toggled.${autotranslation}`}
/>
);
};
export default ChannelAutotranslation;

View file

@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeChannel, observeChannelAutotranslation} from '@queries/servers/channel';
import ChannelAutotranslation from './channel_autotranslation';
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = WithDatabaseArgs & {
channelId: string;
}
const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => {
const channel = observeChannel(database, channelId);
const enabled = observeChannelAutotranslation(database, channelId);
return {
enabled,
displayName: channel.pipe(switchMap((c) => of$(c?.displayName || ''))),
};
});
export default withDatabase(enhanced(ChannelAutotranslation));

View file

@ -14,23 +14,23 @@ type Props = {
channelId: string;
}
const EditChannel = ({channelId}: Props) => {
const ChannelInfoOption = ({channelId}: Props) => {
const {formatMessage} = useIntl();
const title = formatMessage({id: 'screens.channel_edit', defaultMessage: 'Edit Channel'});
const title = formatMessage({id: 'screens.channel_info', defaultMessage: 'Channel info'});
const goToEditChannel = usePreventDoubleTap(useCallback(async () => {
const goToChannelInfo = usePreventDoubleTap(useCallback(async () => {
goToScreen(Screens.CREATE_OR_EDIT_CHANNEL, title, {channelId});
}, [channelId, title]));
return (
<OptionItem
action={goToEditChannel}
action={goToChannelInfo}
label={title}
icon='pencil-outline'
icon='information-outline'
type={Platform.select({ios: 'arrow', default: 'default'})}
testID='channel_info.options.edit_channel.option'
testID='channel_settings.channel_info.option'
/>
);
};
export default EditChannel;
export default ChannelInfoOption;

View file

@ -0,0 +1,141 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {ScrollView, View} from 'react-native';
import {type Edge, SafeAreaView} from 'react-native-safe-area-context';
import ChannelInfoEnableCalls from '@calls/components/channel_info_enable_calls';
import ConvertToChannelLabel from '@components/channel_actions/convert_to_channel/convert_to_channel_label';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import SecurityManager from '@managers/security_manager';
import {dismissModal} from '@screens/navigation';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import Archive from './archive';
import ChannelAutotranslation from './channel_autotranslation';
import ChannelInfoOption from './channel_info';
import ConvertPrivate from './convert_private';
import type {AvailableScreens} from '@typings/screens/navigation';
type Props = {
canArchive: boolean;
canConvert: boolean;
canEnableDisableCalls: boolean;
canManageSettings: boolean;
canUnarchive: boolean;
channelId: string;
closeButtonId?: string;
componentId: AvailableScreens;
convertGMOptionAvailable: boolean;
displayName: string;
isCallsEnabledInChannel: boolean;
canManageAutotranslations: 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,
canManageAutotranslations,
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}/>
}
{canManageAutotranslations &&
<ChannelAutotranslation channelId={channelId}/>
}
{(canArchive || canUnarchive) &&
<>
<View style={styles.separator}/>
<Archive
canArchive={canArchive}
canUnarchive={canUnarchive}
channelId={channelId}
componentId={componentId}
type={type}
/>
</>
}
</ScrollView>
</SafeAreaView>
</View>
);
};
export default ChannelSettings;

View file

@ -107,9 +107,10 @@ const ConvertPrivate = ({canConvert, channelId, displayName}: Props) => {
label={intl.formatMessage({id: 'channel_info.convert_private', defaultMessage: 'Convert to private channel'})}
icon='lock-outline'
type='default'
testID='channel_info.options.convert_private.option'
testID='channel_settings.convert_private.option'
/>
);
};
export default ConvertPrivate;

View file

@ -0,0 +1,197 @@
// 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, observeCanManageChannelAutotranslations} from '@queries/servers/role';
import {
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 canManageAutotranslations = currentUser.pipe(
switchMap((u) => (u ? observeCanManageChannelAutotranslations(database, channelId, u) : of$(false))),
);
return {
canArchive,
canConvert,
canEnableDisableCalls,
canManageSettings,
canUnarchive,
convertGMOptionAvailable,
displayName: channel.pipe(switchMap((c) => of$(c?.displayName || ''))),
isCallsEnabledInChannel,
canManageAutotranslations,
isGuestUser,
type,
};
});
export default withDatabase(withServerUrl(enhanced(ChannelSettings)));

View file

@ -5,7 +5,7 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeChannel} from '@queries/servers/channel';
import {observeChannel, observeIsChannelAutotranslated} from '@queries/servers/channel';
import {observePost} from '@queries/servers/post';
import {observeUser} from '@queries/servers/user';
@ -25,6 +25,9 @@ const enhanced = withObservables([], ({database, thread}: WithDatabaseArgs & {th
author: post.pipe(
switchMap((u) => (u?.userId ? observeUser(database, u.userId) : of$(undefined))),
),
isChannelAutotranslated: post.pipe(
switchMap((p) => (p?.channelId ? observeIsChannelAutotranslated(database, p.channelId) : of$(undefined))),
),
};
});

View file

@ -7,6 +7,7 @@ import {Text, TouchableHighlight, View} from 'react-native';
import {switchToChannelById} from '@actions/remote/channel';
import {fetchAndSwitchToThread} from '@actions/remote/thread';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import FriendlyDate from '@components/friendly_date';
import RemoveMarkdown from '@components/remove_markdown';
@ -17,6 +18,7 @@ import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {usePreventDoubleTap} from '@hooks/utils';
import {bottomSheetModalOptions, showModal, showModalOverCurrentContext} from '@screens/navigation';
import {getPostTranslatedMessage, getPostTranslation} from '@utils/post';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {displayUsername} from '@utils/user';
@ -37,6 +39,7 @@ type Props = {
teammateNameDisplay: string;
testID: string;
thread: ThreadModel;
isChannelAutotranslated: boolean;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
@ -69,6 +72,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
flexDirection: 'row',
marginRight: 12,
overflow: 'hidden',
gap: 6,
},
threadDeleted: {
color: changeOpacity(theme.centerChannelColor, 0.72),
@ -77,7 +81,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
threadStarter: {
color: theme.centerChannelColor,
...typography('Body', 200, 'SemiBold'),
paddingRight: 6,
},
channelNameContainer: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
@ -123,7 +126,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const Thread = ({author, channel, location, post, teammateNameDisplay, testID, thread}: Props) => {
const Thread = ({author, channel, location, post, teammateNameDisplay, testID, thread, isChannelAutotranslated}: Props) => {
const intl = useIntl();
const isTablet = useIsTablet();
const theme = useTheme();
@ -165,6 +168,12 @@ const Thread = ({author, channel, location, post, teammateNameDisplay, testID, t
return null;
}
const translation = getPostTranslation(post, intl.locale);
let message = post.message;
if (isChannelAutotranslated && post.type === '' && translation?.state === 'ready') {
message = getPostTranslatedMessage(message, translation);
}
const threadStarterName = displayUsername(author, intl.locale, teammateNameDisplay);
const threadItemTestId = `${testID}.thread_item.${thread.id}`;
@ -212,7 +221,7 @@ const Thread = ({author, channel, location, post, teammateNameDisplay, testID, t
{threadStarterName}
</Text>
);
if (post?.message) {
if (message) {
postBody = (
<Text numberOfLines={2}>
<RemoveMarkdown
@ -222,7 +231,7 @@ const Thread = ({author, channel, location, post, teammateNameDisplay, testID, t
enableHardBreak={true}
enableSoftBreak={true}
baseStyle={styles.message}
value={post.message.substring(0, 100)} // This substring helps to avoid ANR's
value={message.substring(0, 100)} // This substring helps to avoid ANR's
/>
</Text>
);
@ -244,6 +253,13 @@ const Thread = ({author, channel, location, post, teammateNameDisplay, testID, t
<View style={styles.header}>
<View style={styles.headerInfoContainer}>
{name}
{isChannelAutotranslated && post.type === '' && translation?.state === 'ready' && (
<CompassIcon
name='translate'
size={16}
color={changeOpacity(theme.centerChannelColor, 0.56)}
/>
)}
{threadStarterName !== channel?.displayName && (
<View style={styles.channelNameContainer}>
<TouchableWithFeedback

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