Compare commits

...

53 Commits

Author SHA1 Message Date
Daniel Espino García
15fd6925b3 Performance fixes and fix manual sort (#7190)
* Performance fixes and fix manual sort

* Fix test

* Use combineLatestWith

* Revert unread on top
2023-03-07 19:25:25 +01:00
Daniel Espino García
571070e284 Fix race condition when the same websocket gets initialized twice (#7185)
* Fix race condition when the same websocket gets initialized twice

* Bump network library
2023-03-07 19:13:19 +01:00
Elias Nahum
ab8a43032e Refactor category channels to react to setting changes and apply the correct order (#7170)
* Refactor category channels to react to setting changes and apply the correct order

* feedback review
2023-03-03 15:54:12 +02:00
Elias Nahum
6904be23da Fix push notification token registration race/missing (#7183) 2023-03-03 12:14:32 +02:00
Elias Nahum
6bc7c05ccb support WS connection over TLS1.3 (#7182)
* support WS connection over TLS1.3

* fix updateDraftMessage on unmount
2023-03-03 11:33:48 +02:00
Elias Nahum
4b142483a5 Fix display name when open own DM (#7181) 2023-03-02 16:58:31 +02:00
Elias Nahum
63674e2a43 fix entry for tablets (#7179) 2023-03-02 16:56:26 +02:00
Elias Nahum
cdaf1f50e7 use sourceScreen instead of location in post options (#7176) 2023-03-02 12:47:58 +02:00
Elias Nahum
10735dcbf1 trigger Search when hardware keyboard enter key is pressed (#7174) 2023-03-01 15:20:02 +02:00
Elias Nahum
619decd253 Fix potential reaction crash (#7172) 2023-03-01 15:19:55 +02:00
Elias Nahum
55f18bcfc3 ignore leading and trailing spaces when editing profile (#7173) 2023-03-01 15:19:47 +02:00
Elias Nahum
870336142a Fix iOS push notification when set as generic message with sender name (#7171) 2023-03-01 15:19:39 +02:00
Elisabeth Kulzer
27d7875dd7 Detox: Android - fix smoke server login test (#7157)
* Detox: Android - fix smoke server login test
---------

Co-authored-by: Mattermost Build <build@mattermost.com>
2023-02-28 17:14:07 +01:00
master7
0309d7a60b Translated using Weblate (Polish)
Currently translated at 100.0% (1008 of 1008 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/pl/
2023-02-28 10:55:22 +02:00
Tom De Moor
5a497ab15b Translated using Weblate (Dutch)
Currently translated at 100.0% (1008 of 1008 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/nl/
2023-02-28 10:55:22 +02:00
Matthew Williams
3daa91ce9d Translated using Weblate (English (Australia))
Currently translated at 100.0% (1008 of 1008 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/en_AU/
2023-02-28 10:55:22 +02:00
kaakaa
67a28dd926 Translated using Weblate (Japanese)
Currently translated at 100.0% (1008 of 1008 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/ja/
2023-02-28 10:55:22 +02:00
jprusch
ef3e45f523 Translated using Weblate (German)
Currently translated at 100.0% (1008 of 1008 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/de/
2023-02-28 10:55:22 +02:00
master7
b0c61b220c Translated using Weblate (Polish)
Currently translated at 100.0% (997 of 997 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/pl/
2023-02-28 10:55:22 +02:00
jprusch
139be16b05 Translated using Weblate (German)
Currently translated at 100.0% (997 of 997 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/de/
2023-02-28 10:55:22 +02:00
Hosted Weblate
c7e5adbc3e Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/
2023-02-28 10:55:22 +02:00
MArtin Johnson
bc4d89c3e1 Translated using Weblate (Swedish)
Currently translated at 100.0% (995 of 995 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/sv/
2023-02-28 10:55:22 +02:00
master7
e75791c98d Translated using Weblate (Polish)
Currently translated at 100.0% (995 of 995 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/pl/
2023-02-28 10:55:22 +02:00
Cédric Stocké
bf33df84d6 Translated using Weblate (Italian)
Currently translated at 99.8% (994 of 995 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/it/
2023-02-28 10:55:22 +02:00
jprusch
b6f1f8999d Translated using Weblate (German)
Currently translated at 100.0% (995 of 995 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/de/
2023-02-28 10:55:22 +02:00
Daniel Espino García
f6610693e2 Bump app build number to 459 (#7165)
Co-authored-by: Daniel Espino <danielespino@MacBook-Pro-de-Daniel.local>
2023-02-24 17:37:05 +02:00
Claudio Costa
b90275bff3 Fix local build (#7163) 2023-02-24 15:35:33 +02:00
Daniel Espino García
7c6b34afe3 Minor performance fixes on message send (#7164) 2023-02-24 15:35:20 +02:00
Claudio Costa
ac3bd14891 [MM-50806] Calls: fix crash on joining call (#7159)
* Calls: fix crash on joining call

* update Podfile.lock

---------

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2023-02-24 13:25:25 +02:00
Elias Nahum
d61fbd3180 Various fixes (#7161)
* Save message draft when post input is unmounted

* Fix switch CRT on/off

* Handle iPad on Stage Manager

* iOS Share Extension to use LRU cache instead of file cache

* Support building android as aab

* use handleReconnect instead of appEntry on handleCRTToggled

* show skin tone selector tutorial after running all interactions

* Update app/actions/remote/preference.ts

Co-authored-by: Daniel Espino García <larkox@gmail.com>

* fix lint

---------

Co-authored-by: Daniel Espino García <larkox@gmail.com>
2023-02-24 13:02:05 +02:00
Elias Nahum
2fc1386b78 feat: Channel notification preferences (#7160)
* feat: Channel notification preferences

* feedback review

* use button color for the icon
2023-02-24 12:41:36 +02:00
Elisabeth Kulzer
c6dc00e4df Fix connect to server 2023-02-23 12:17:37 +01:00
Daniel Espino García
9f84ab79ce Only call app entry on websocket reconnect (#7065)
* Only call app entry on websocket reconnect

* Handle notification on its own entry and run app entry on websocket initialization

* Fix notification entry issues

* Fix login entry and add retry on entry failure

* feedback review

* Put back handleEntryAfterLoadNavigation before the batching

---------

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2023-02-23 10:11:34 +01:00
Daniel Espino García
98f25046af Fix double tilde by waiting for text values to propagate to the native side (#7132)
Co-authored-by: Daniel Espino <danielespino@MacBook-Pro-de-Daniel.local>
2023-02-22 18:03:21 +01:00
Daniel Espino García
bc3ace278b Remove posts in thread only when removing root posts (#7116) 2023-02-22 16:20:29 +01:00
Daniel Espino García
5cdcbfb12a Fix Group Message member count on GraphQL scenario (#7151)
* Fix Group Message member count on GraphQL scenario

* Fix lint

* Use fetchProfilesInGroupChannels instead of fetchMissingDirectChannelsInfo

* Use reduce instead of filter map

* Simplify using set in the reduce
2023-02-22 10:12:58 +01:00
Tom De Moor
854cbbbe7f Deleted translation using Weblate (Vietnamese) 2023-02-21 11:46:03 +02:00
Weblate
6f6e808cf8 Added translation using Weblate (Vietnamese) 2023-02-21 11:46:03 +02:00
Anonymous
95759a5632 Translated using Weblate (Chinese (Traditional))
Currently translated at 26.7% (266 of 995 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/zh_Hant/
2023-02-21 11:46:03 +02:00
Anonymous
472ded8cee Translated using Weblate (Chinese (Simplified))
Currently translated at 38.1% (380 of 995 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/zh_Hans/
2023-02-21 11:46:03 +02:00
Anonymous
f7c2c9b01a Translated using Weblate (Ukrainian)
Currently translated at 27.0% (269 of 995 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/uk/
2023-02-21 11:46:03 +02:00
Anonymous
b023751656 Translated using Weblate (Turkish)
Currently translated at 99.3% (989 of 995 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/tr/
2023-02-21 11:46:03 +02:00
Anonymous
d5a76a6e9b Translated using Weblate (Romanian)
Currently translated at 27.5% (274 of 995 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/ro/
2023-02-21 11:46:03 +02:00
Anonymous
22efcbcca7 Translated using Weblate (Portuguese (Brazil))
Currently translated at 36.5% (364 of 995 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/pt_BR/
2023-02-21 11:46:03 +02:00
Anonymous
1f9ca219ae Translated using Weblate (Korean)
Currently translated at 55.8% (556 of 995 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/ko/
2023-02-21 11:46:03 +02:00
Anonymous
f769500ba3 Translated using Weblate (Japanese)
Currently translated at 97.7% (973 of 995 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/ja/
2023-02-21 11:46:03 +02:00
Anonymous
12aa21018a Translated using Weblate (Hungarian)
Currently translated at 38.3% (382 of 995 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/hu/
2023-02-21 11:46:03 +02:00
Anonymous
31b3e9cf01 Translated using Weblate (French)
Currently translated at 94.2% (938 of 995 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/fr/
2023-02-21 11:46:03 +02:00
Anonymous
39d8394ca8 Translated using Weblate (Persian)
Currently translated at 87.7% (873 of 995 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/fa/
2023-02-21 11:46:03 +02:00
Anonymous
d2124151be Translated using Weblate (Spanish)
Currently translated at 94.2% (938 of 995 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/es/
2023-02-21 11:46:03 +02:00
Anonymous
5427468e2f Translated using Weblate (Bulgarian)
Currently translated at 34.6% (345 of 995 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/bg/
2023-02-21 11:46:03 +02:00
Sajjad Jazini
c8f36fb544 Translated using Weblate (Persian)
Currently translated at 87.7% (873 of 995 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/fa/
2023-02-21 11:46:03 +02:00
Sajjad Jazini
5ccf042801 Translated using Weblate (Persian)
Currently translated at 85.3% (849 of 995 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/fa/
2023-02-21 11:46:03 +02:00
159 changed files with 7358 additions and 1440 deletions

5
.gitignore vendored
View File

@@ -7,6 +7,9 @@ mattermost.keystore
tmp/
.env
env.d.ts
*.apk
*.aab
*.ipa
*/**/compass-icons.ttf
@@ -30,8 +33,6 @@ xcuserdata
*.moved-aside
DerivedData
*.hmap
*.ipa
*.apk
*.xcuserstate
project.xcworkspace
ios/Pods

View File

@@ -110,7 +110,7 @@ android {
applicationId "com.mattermost.rnbeta"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 458
versionCode 459
versionName "2.1.0"
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'

View File

@@ -11,6 +11,7 @@ import com.mattermost.helpers.database_extension.queryCurrentUserId
import com.nozbe.watermelondb.Database
import java.text.Collator
import java.util.Locale
import kotlin.math.max
suspend fun PushNotificationDataRunnable.Companion.fetchMyChannel(db: Database, serverUrl: String, channelId: String, isCRTEnabled: Boolean): Triple<ReadableMap?, ReadableMap?, ReadableArray?> {
val channel = fetch(serverUrl, "/api/v4/channels/$channelId")

View File

@@ -1,58 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import {getPostById, queryPostsInChannel, queryPostsInThread} from '@queries/servers/post';
import {logError} from '@utils/log';
export const updatePostSinceCache = async (serverUrl: string, notification: NotificationWithData) => {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
if (notification.payload?.channel_id) {
const chunks = await queryPostsInChannel(database, notification.payload.channel_id).fetch();
if (chunks.length) {
const recent = chunks[0];
const lastPost = await getPostById(database, notification.payload.post_id);
if (lastPost) {
await operator.database.write(async () => {
await recent.update(() => {
recent.latest = lastPost.createAt;
});
});
}
}
}
return {};
} catch (error) {
logError('Failed updatePostSinceCache', error);
return {error};
}
};
export const updatePostsInThreadsSinceCache = async (serverUrl: string, notification: NotificationWithData) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
if (notification.payload?.root_id) {
const {database} = operator;
const chunks = await queryPostsInThread(database, notification.payload.root_id).fetch();
if (chunks.length) {
const recent = chunks[0];
const lastPost = await getPostById(database, notification.payload.post_id);
if (lastPost) {
await operator.database.write(async () => {
await recent.update(() => {
recent.latest = lastPost.createAt;
});
});
}
}
}
return {};
} catch (error) {
return {error};
}
};

View File

@@ -1,87 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {dataRetentionCleanup, setLastServerVersionCheck} from '@actions/local/systems';
import {setLastServerVersionCheck} from '@actions/local/systems';
import {fetchConfigAndLicense} from '@actions/remote/systems';
import DatabaseManager from '@database/manager';
import {prepareCommonSystemValues, getCurrentTeamId, getWebSocketLastDisconnected, getCurrentChannelId, getConfig, getLicense} from '@queries/servers/system';
import {getCurrentUser} from '@queries/servers/user';
import {setTeamLoading} from '@store/team_load_store';
import WebsocketManager from '@managers/websocket_manager';
import {prepareCommonSystemValues} from '@queries/servers/system';
import {deleteV1Data} from '@utils/file';
import {isTablet} from '@utils/helpers';
import {logInfo} from '@utils/log';
import {handleEntryAfterLoadNavigation, registerDeviceToken, syncOtherServers, verifyPushProxy} from './common';
import {deferredAppEntryActions, entry} from './gql_common';
import {verifyPushProxy} from './common';
export async function appEntry(serverUrl: string, since = 0, isUpgrade = false) {
export async function appEntry(serverUrl: string, since = 0) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
if (!since) {
registerDeviceToken(serverUrl);
if (Object.keys(DatabaseManager.serverDatabases).length === 1) {
await setLastServerVersionCheck(serverUrl, true);
}
}
// Run data retention cleanup
await dataRetentionCleanup(serverUrl);
// clear lastUnreadChannelId
const removeLastUnreadChannelId = await prepareCommonSystemValues(operator, {lastUnreadChannelId: ''});
if (removeLastUnreadChannelId) {
await operator.batchRecords(removeLastUnreadChannelId, 'appEntry - removeLastUnreadChannelId');
}
const {database} = operator;
const currentTeamId = await getCurrentTeamId(database);
const currentChannelId = await getCurrentChannelId(database);
const lastDisconnectedAt = (await getWebSocketLastDisconnected(database)) || since;
setTeamLoading(serverUrl, true);
const entryData = await entry(serverUrl, currentTeamId, currentChannelId, since);
if ('error' in entryData) {
setTeamLoading(serverUrl, false);
return {error: entryData.error};
}
const {models, initialTeamId, initialChannelId, prefData, teamData, chData, meData} = entryData;
if (isUpgrade && meData?.user) {
const isTabletDevice = await isTablet();
const me = await prepareCommonSystemValues(operator, {
currentUserId: meData.user.id,
currentTeamId: initialTeamId,
currentChannelId: isTabletDevice ? initialChannelId : undefined,
});
if (me?.length) {
await operator.batchRecords(me, 'appEntry - upgrade store me');
}
}
await handleEntryAfterLoadNavigation(serverUrl, teamData.memberships || [], chData?.memberships || [], currentTeamId, currentChannelId, initialTeamId, initialChannelId);
const dt = Date.now();
await operator.batchRecords(models, 'appEntry');
logInfo('ENTRY MODELS BATCHING TOOK', `${Date.now() - dt}ms`);
setTeamLoading(serverUrl, false);
const {id: currentUserId, locale: currentUserLocale} = meData?.user || (await getCurrentUser(database))!;
const config = await getConfig(database);
const license = await getLicense(database);
await deferredAppEntryActions(serverUrl, lastDisconnectedAt, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, initialTeamId);
if (!since) {
// Load data from other servers
syncOtherServers(serverUrl);
}
WebsocketManager.openAll();
verifyPushProxy(serverUrl);
return {userId: currentUserId};
return {};
}
export async function upgradeEntry(serverUrl: string) {
@@ -89,7 +40,7 @@ export async function upgradeEntry(serverUrl: string) {
try {
const configAndLicense = await fetchConfigAndLicense(serverUrl, false);
const entryData = await appEntry(serverUrl, 0, true);
const entryData = await appEntry(serverUrl, 0);
const error = configAndLicense.error || entryData.error;
if (!error) {

View File

@@ -1,9 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {markChannelAsViewed} from '@actions/local/channel';
import {dataRetentionCleanup} from '@actions/local/systems';
import {fetchMissingDirectChannelsInfo, fetchMyChannelsForTeam, handleKickFromChannel, markChannelAsRead, MyChannelsRequest} from '@actions/remote/channel';
import {fetchMissingDirectChannelsInfo, fetchMyChannelsForTeam, handleKickFromChannel, MyChannelsRequest} from '@actions/remote/channel';
import {fetchGroupsForMember} from '@actions/remote/groups';
import {fetchPostsForUnreadChannels} from '@actions/remote/post';
import {MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference';
@@ -11,8 +9,7 @@ import {fetchRoles} from '@actions/remote/role';
import {fetchConfigAndLicense} from '@actions/remote/systems';
import {fetchMyTeams, fetchTeamsChannelsAndUnreadPosts, handleKickFromTeam, MyTeamsRequest, updateCanJoinTeams} from '@actions/remote/team';
import {syncTeamThreads} from '@actions/remote/thread';
import {autoUpdateTimezone, fetchMe, MyUserRequest, updateAllUsersSince} from '@actions/remote/user';
import {gqlAllChannels} from '@client/graphQL/entry';
import {fetchMe, MyUserRequest, updateAllUsersSince} from '@actions/remote/user';
import {General, Preferences, Screens} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import {PUSH_PROXY_RESPONSE_NOT_AVAILABLE, PUSH_PROXY_RESPONSE_UNKNOWN, PUSH_PROXY_STATUS_NOT_AVAILABLE, PUSH_PROXY_STATUS_UNKNOWN, PUSH_PROXY_STATUS_VERIFIED} from '@constants/push_proxy';
@@ -22,16 +19,13 @@ import {selectDefaultTeam} from '@helpers/api/team';
import {DEFAULT_LOCALE} from '@i18n';
import NetworkManager from '@managers/network_manager';
import {getDeviceToken} from '@queries/app/global';
import {getAllServers} from '@queries/app/servers';
import {prepareMyChannelsForTeam, queryAllChannelsForTeam, queryChannelsById} from '@queries/servers/channel';
import {queryAllChannelsForTeam, queryChannelsById} from '@queries/servers/channel';
import {prepareModels, truncateCrtRelatedTables} from '@queries/servers/entry';
import {getHasCRTChanged} from '@queries/servers/preference';
import {getConfig, getCurrentChannelId, getCurrentTeamId, getCurrentUserId, getPushVerificationStatus, getWebSocketLastDisconnected, setCurrentTeamAndChannelId} from '@queries/servers/system';
import {getConfig, getCurrentChannelId, getCurrentTeamId, getPushVerificationStatus, getWebSocketLastDisconnected, setCurrentTeamAndChannelId} from '@queries/servers/system';
import {deleteMyTeams, getAvailableTeamIds, getTeamChannelHistory, queryMyTeams, queryMyTeamsByIds, queryTeamsById} from '@queries/servers/team';
import {getIsCRTEnabled} from '@queries/servers/thread';
import NavigationStore from '@store/navigation_store';
import {isDMorGM, sortChannelsByDisplayName} from '@utils/channel';
import {getMemberChannelsFromGQLQuery, gqlToClientChannelMembership} from '@utils/graphql';
import {isTablet} from '@utils/helpers';
import {logDebug} from '@utils/log';
import {processIsCRTEnabled} from '@utils/thread';
@@ -112,6 +106,12 @@ export const entryRest = async (serverUrl: string, teamId?: string, channelId?:
}
const {initialTeamId, teamData, chData, prefData, meData, removeTeamIds, removeChannelIds, isCRTEnabled} = fetchedData;
const chError = chData?.error as ClientError | undefined;
if (chError?.status_code === 403) {
// if the user does not have appropriate permissions, which means the user those not belong to the team,
// we set it as there is no errors, so that the teams and others can be properly handled
chData!.error = undefined;
}
const error = teamData.error || chData?.error || prefData.error || meData.error;
if (error) {
return {error};
@@ -312,13 +312,8 @@ export async function restDeferredAppEntryActions(
serverUrl: string, since: number, currentUserId: string, currentUserLocale: string, preferences: PreferenceType[] | undefined,
config: ClientConfig, license: ClientLicense | undefined, teamData: MyTeamsRequest, chData: MyChannelsRequest | undefined,
initialTeamId?: string, initialChannelId?: string) {
// defer sidebar DM & GM profiles
let channelsToFetchProfiles: Set<Channel>|undefined;
setTimeout(async () => {
if (chData?.channels?.length && chData.memberships?.length) {
const directChannels = chData.channels.filter(isDMorGM);
channelsToFetchProfiles = new Set<Channel>(directChannels);
// defer fetching posts for unread channels on initial team
fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannelId);
}
@@ -350,8 +345,11 @@ export async function restDeferredAppEntryActions(
// Fetch groups for current user
fetchGroupsForMember(serverUrl, currentUserId);
// defer sidebar DM & GM profiles
setTimeout(async () => {
if (channelsToFetchProfiles?.size) {
const directChannels = chData?.channels?.filter(isDMorGM);
const channelsToFetchProfiles = new Set<Channel>(directChannels);
if (channelsToFetchProfiles.size) {
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config.LockTeammateNameDisplay, config.TeammateNameDisplay, license);
fetchMissingDirectChannelsInfo(serverUrl, Array.from(channelsToFetchProfiles), currentUserLocale, teammateDisplayNameSetting, currentUserId);
}
@@ -374,107 +372,6 @@ export const registerDeviceToken = async (serverUrl: string) => {
return {error: undefined};
};
export const syncOtherServers = async (serverUrl: string) => {
const servers = await getAllServers();
for (const server of servers) {
if (server.url !== serverUrl && server.lastActiveAt > 0) {
registerDeviceToken(server.url);
syncAllChannelMembersAndThreads(server.url).then(() => {
dataRetentionCleanup(server.url);
});
autoUpdateTimezone(server.url);
}
}
};
const syncAllChannelMembersAndThreads = async (serverUrl: string) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return;
}
const config = await getConfig(database);
if (config?.FeatureFlagGraphQL === 'true') {
const error = await graphQLSyncAllChannelMembers(serverUrl);
if (error) {
logDebug('failed graphQL, falling back to rest', error);
restSyncAllChannelMembers(serverUrl);
}
} else {
restSyncAllChannelMembers(serverUrl);
}
};
const graphQLSyncAllChannelMembers = async (serverUrl: string) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return 'Server database not found';
}
const {database} = operator;
const response = await gqlAllChannels(serverUrl);
if ('error' in response) {
return response.error;
}
if (response.errors) {
return response.errors[0].message;
}
const userId = await getCurrentUserId(database);
const channels = getMemberChannelsFromGQLQuery(response.data);
const memberships = response.data.channelMembers?.map((m) => gqlToClientChannelMembership(m, userId));
if (channels && memberships) {
const modelPromises = await prepareMyChannelsForTeam(operator, '', channels, memberships, undefined, true);
const models = (await Promise.all(modelPromises)).flat();
if (models.length) {
await operator.batchRecords(models, 'graphQLSyncAllChannelMembers');
}
}
const isCRTEnabled = await getIsCRTEnabled(database);
if (isCRTEnabled) {
const myTeams = await queryMyTeams(operator.database).fetch();
for await (const myTeam of myTeams) {
// need to await here since GM/DM threads in different teams overlap
await syncTeamThreads(serverUrl, myTeam.id);
}
}
return '';
};
const restSyncAllChannelMembers = async (serverUrl: string) => {
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch {
return;
}
try {
const myTeams = await client.getMyTeams();
const preferences = await client.getMyPreferences();
const config = await client.getClientConfigOld();
let excludeDirect = false;
for await (const myTeam of myTeams) {
fetchMyChannelsForTeam(serverUrl, myTeam.id, false, 0, false, excludeDirect);
excludeDirect = true;
if (preferences && processIsCRTEnabled(preferences, config.CollapsedThreads, config.FeatureFlagCollapsedThreads, config.Version)) {
// need to await here since GM/DM threads in different teams overlap
await syncTeamThreads(serverUrl, myTeam.id);
}
}
} catch {
// Do nothing
}
};
export async function verifyPushProxy(serverUrl: string) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
@@ -539,7 +436,14 @@ export async function handleEntryAfterLoadNavigation(
const isThreadsMounted = mountedScreens.includes(Screens.THREAD);
const tabletDevice = await isTablet();
if (currentTeamIdAfterLoad !== currentTeamId) {
if (!currentTeamIdAfterLoad) {
// First load or no team
if (tabletDevice) {
await setCurrentTeamAndChannelId(operator, initialTeamId, initialChannelId);
} else {
await setCurrentTeamAndChannelId(operator, initialTeamId, '');
}
} else if (currentTeamIdAfterLoad !== currentTeamId) {
// Switched teams while loading
if (!teamMembers.find((t) => t.team_id === currentTeamIdAfterLoad && t.delete_at === 0)) {
await handleKickFromTeam(serverUrl, currentTeamIdAfterLoad);
@@ -561,9 +465,6 @@ export async function handleEntryAfterLoadNavigation(
} else {
await setCurrentTeamAndChannelId(operator, initialTeamId, initialChannelId);
}
} else if (tabletDevice && initialChannelId === currentChannelId) {
await markChannelAsRead(serverUrl, initialChannelId);
markChannelAsViewed(serverUrl, initialChannelId);
}
} catch (error) {
logDebug('could not manage the entry after load navigation', error);

View File

@@ -7,9 +7,9 @@ import {fetchPostsForUnreadChannels} from '@actions/remote/post';
import {fetchDataRetentionPolicy} from '@actions/remote/systems';
import {MyTeamsRequest, updateCanJoinTeams} from '@actions/remote/team';
import {syncTeamThreads} from '@actions/remote/thread';
import {autoUpdateTimezone, updateAllUsersSince} from '@actions/remote/user';
import {autoUpdateTimezone, fetchProfilesInGroupChannels, updateAllUsersSince} from '@actions/remote/user';
import {gqlEntry, gqlEntryChannels, gqlOtherChannels} from '@client/graphQL/entry';
import {Preferences} from '@constants';
import {General, Preferences} from '@constants';
import DatabaseManager from '@database/manager';
import {getPreferenceValue} from '@helpers/api/preference';
import {selectDefaultTeam} from '@helpers/api/team';
@@ -28,6 +28,8 @@ import type ClientError from '@client/rest/error';
import type {Database} from '@nozbe/watermelondb';
import type ChannelModel from '@typings/database/models/servers/channel';
const FETCH_MISSING_GM_TIMEOUT = 2500;
export async function deferredAppEntryGraphQLActions(
serverUrl: string,
since: number,
@@ -98,6 +100,19 @@ export async function deferredAppEntryGraphQLActions(
updateCanJoinTeams(serverUrl);
updateAllUsersSince(serverUrl, since);
// defer sidebar GM profiles
setTimeout(async () => {
const gmIds = chData?.channels?.reduce<Set<string>>((acc, v) => {
if (v?.type === General.GM_CHANNEL) {
acc.add(v.id);
}
return acc;
}, new Set<string>());
if (gmIds?.size) {
fetchProfilesInGroupChannels(serverUrl, Array.from(gmIds));
}
}, FETCH_MISSING_GM_TIMEOUT);
return {error: undefined};
}

View File

@@ -1,81 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {switchToChannelById} from '@actions/remote/channel';
import {fetchConfigAndLicense} from '@actions/remote/systems';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import {setCurrentTeamAndChannelId} from '@queries/servers/system';
import {setTeamLoading} from '@store/team_load_store';
import {isTablet} from '@utils/helpers';
import {deferredAppEntryActions, entry} from './gql_common';
import type {Client} from '@client/rest';
import {getServerCredentials} from '@init/credentials';
import WebsocketManager from '@managers/websocket_manager';
type AfterLoginArgs = {
serverUrl: string;
user: UserProfile;
deviceToken?: string;
}
export async function loginEntry({serverUrl, user, deviceToken}: AfterLoginArgs): Promise<{error?: any; hasTeams?: boolean; time?: number}> {
const dt = Date.now();
export async function loginEntry({serverUrl}: AfterLoginArgs): Promise<{error?: any}> {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
if (deviceToken) {
try {
client.attachDevice(deviceToken);
} catch {
// do nothing, the token could've failed to attach to the session but is not a blocker
}
}
try {
const clData = await fetchConfigAndLicense(serverUrl, false);
if (clData.error) {
return {error: clData.error};
}
setTeamLoading(serverUrl, true);
const entryData = await entry(serverUrl, '', '');
if ('error' in entryData) {
setTeamLoading(serverUrl, false);
return {error: entryData.error};
const credentials = await getServerCredentials(serverUrl);
if (credentials?.token) {
WebsocketManager.createClient(serverUrl, credentials.token);
await WebsocketManager.initializeClient(serverUrl);
}
const {models, initialTeamId, initialChannelId, prefData, teamData, chData} = entryData;
const isTabletDevice = await isTablet();
let switchToChannel = false;
if (initialChannelId && isTabletDevice) {
switchToChannel = true;
switchToChannelById(serverUrl, initialChannelId, initialTeamId);
} else {
setCurrentTeamAndChannelId(operator, initialTeamId, '');
}
await operator.batchRecords(models, 'loginEntry');
setTeamLoading(serverUrl, false);
const config = clData.config || {} as ClientConfig;
const license = clData.license || {} as ClientLicense;
deferredAppEntryActions(serverUrl, 0, user.id, user.locale, prefData.preferences, config, license, teamData, chData, initialTeamId, switchToChannel ? initialChannelId : undefined);
return {time: Date.now() - dt, hasTeams: Boolean(teamData.teams?.length)};
return {};
} catch (error) {
return {error};
}

View File

@@ -1,47 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {switchToChannelById} from '@actions/remote/channel';
import {fetchMyChannel, switchToChannelById} from '@actions/remote/channel';
import {fetchPostById} from '@actions/remote/post';
import {fetchMyTeam} from '@actions/remote/team';
import {fetchAndSwitchToThread} from '@actions/remote/thread';
import {Screens} from '@constants';
import {getDefaultThemeByAppearance} from '@context/theme';
import DatabaseManager from '@database/manager';
import WebsocketManager from '@managers/websocket_manager';
import {getMyChannel} from '@queries/servers/channel';
import {getPostById} from '@queries/servers/post';
import {queryThemePreferences} from '@queries/servers/preference';
import {getConfig, getCurrentTeamId, getLicense, getWebSocketLastDisconnected, setCurrentTeamAndChannelId} from '@queries/servers/system';
import {getCurrentTeamId} from '@queries/servers/system';
import {getMyTeamById} from '@queries/servers/team';
import {getIsCRTEnabled} from '@queries/servers/thread';
import {getCurrentUser} from '@queries/servers/user';
import EphemeralStore from '@store/ephemeral_store';
import NavigationStore from '@store/navigation_store';
import {setTeamLoading} from '@store/team_load_store';
import {isTablet} from '@utils/helpers';
import {emitNotificationError} from '@utils/notification';
import {setThemeDefaults, updateThemeIfNeeded} from '@utils/theme';
import {syncOtherServers} from './common';
import {deferredAppEntryActions, entry} from './gql_common';
import type ClientError from '@client/rest/error';
import type MyChannelModel from '@typings/database/models/servers/my_channel';
import type MyTeamModel from '@typings/database/models/servers/my_team';
import type PostModel from '@typings/database/models/servers/post';
export async function pushNotificationEntry(serverUrl: string, notification: NotificationWithData) {
export async function pushNotificationEntry(serverUrl: string, notification: NotificationData) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
// We only reach this point if we have a channel Id in the notification payload
const channelId = notification.payload!.channel_id!;
const rootId = notification.payload!.root_id!;
const channelId = notification.channel_id!;
const rootId = notification.root_id!;
const {database} = operator;
const currentTeamId = await getCurrentTeamId(database);
const currentServerUrl = await DatabaseManager.getActiveServerUrl();
const lastDisconnectedAt = await getWebSocketLastDisconnected(database);
let isDirectChannel = false;
let teamId = notification.payload?.team_id;
let teamId = notification.team_id;
if (!teamId) {
// If the notification payload does not have a teamId we assume is a DM/GM
isDirectChannel = true;
teamId = currentTeamId;
}
@@ -61,91 +58,62 @@ export async function pushNotificationEntry(serverUrl: string, notification: Not
updateThemeIfNeeded(theme, true);
}
await NavigationStore.waitUntilScreenHasLoaded(Screens.HOME);
// To make the switch faster we determine if we already have the team & channel
const myChannel = await getMyChannel(database, channelId);
const myTeam = await getMyTeamById(database, teamId);
let myChannel: MyChannelModel | ChannelMembership | undefined = await getMyChannel(database, channelId);
let myTeam: MyTeamModel | TeamMembership | undefined = await getMyTeamById(database, teamId);
if (!myTeam) {
const resp = await fetchMyTeam(serverUrl, teamId);
if (resp.error) {
if ((resp.error as ClientError).status_code === 403) {
emitNotificationError('Team');
} else {
emitNotificationError('Connection');
}
} else {
myTeam = resp.memberships?.[0];
}
}
if (!myChannel) {
const resp = await fetchMyChannel(serverUrl, teamId, channelId);
if (resp.error) {
if ((resp.error as ClientError).status_code === 403) {
emitNotificationError('Channel');
} else {
emitNotificationError('Connection');
}
} else {
myChannel = resp.memberships?.[0];
}
}
const isCRTEnabled = await getIsCRTEnabled(database);
const isThreadNotification = isCRTEnabled && Boolean(rootId);
let switchedToScreen = false;
let switchedToChannel = false;
if (myChannel && myTeam) {
if (isThreadNotification) {
await fetchAndSwitchToThread(serverUrl, rootId, true);
} else {
switchedToChannel = true;
await switchToChannelById(serverUrl, channelId, teamId);
}
switchedToScreen = true;
}
let post: PostModel | Post | undefined = await getPostById(database, rootId);
if (!post) {
const resp = await fetchPostById(serverUrl, rootId);
post = resp.post;
}
setTeamLoading(serverUrl, true);
const entryData = await entry(serverUrl, teamId, channelId);
if ('error' in entryData) {
setTeamLoading(serverUrl, false);
return {error: entryData.error};
}
const {models, initialTeamId, initialChannelId, prefData, teamData, chData} = entryData;
const actualRootId = post && ('root_id' in post ? post.root_id : post.rootId);
// There is a chance that after the above request returns
// the user is no longer part of the team or channel
// that triggered the notification (rare but possible)
let selectedTeamId = teamId;
let selectedChannelId = channelId;
if (initialTeamId !== teamId) {
// We are no longer a part of the team that the notification belongs to
// Immediately set the new team as the current team in the database so that the UI
// renders the correct team.
selectedTeamId = initialTeamId;
if (!isDirectChannel) {
selectedChannelId = initialChannelId;
}
}
if (!switchedToScreen) {
const isTabletDevice = await isTablet();
if (isTabletDevice || (channelId === selectedChannelId)) {
// Make switch again to get the missing data and make sure the team is the correct one
switchedToScreen = true;
if (isThreadNotification) {
if (actualRootId) {
await fetchAndSwitchToThread(serverUrl, actualRootId, true);
} else if (post) {
await fetchAndSwitchToThread(serverUrl, rootId, true);
} else {
switchedToChannel = true;
await switchToChannelById(serverUrl, channelId, teamId);
emitNotificationError('Post');
}
} else if (teamId !== selectedTeamId || channelId !== selectedChannelId) {
// If in the end the selected team or channel is different than the one from the notification
// we switch again
await setCurrentTeamAndChannelId(operator, selectedTeamId, selectedChannelId);
} else {
await switchToChannelById(serverUrl, channelId, teamId);
}
}
if (teamId !== selectedTeamId) {
emitNotificationError('Team');
} else if (channelId !== selectedChannelId) {
emitNotificationError('Channel');
}
WebsocketManager.openAll();
// Waiting for the screen to display fixes a race condition when fetching and storing data
if (switchedToChannel) {
await NavigationStore.waitUntilScreenHasLoaded(Screens.CHANNEL);
} else if (switchedToScreen && isThreadNotification) {
await NavigationStore.waitUntilScreenHasLoaded(Screens.THREAD);
}
await operator.batchRecords(models, 'pushNotificationEntry');
setTeamLoading(serverUrl, false);
const {id: currentUserId, locale: currentUserLocale} = (await getCurrentUser(operator.database))!;
const config = await getConfig(database);
const license = await getLicense(database);
await deferredAppEntryActions(serverUrl, lastDisconnectedAt, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, selectedTeamId, selectedChannelId);
syncOtherServers(serverUrl);
return {userId: currentUserId};
return {};
}

View File

@@ -137,7 +137,7 @@ export const backgroundNotification = async (serverUrl: string, notification: No
serverUrl, teamId,
channel ? [channel] : [],
myChannel ? [myChannel] : [],
true, isCRTEnabled,
false, isCRTEnabled,
);
if (data.categoryChannels?.length && channel) {

View File

@@ -1,12 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {General, Preferences} from '@constants';
import {DeviceEventEmitter} from 'react-native';
import {handleReconnect} from '@actions/websocket';
import {Events, General, Preferences} from '@constants';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import {getChannelById} from '@queries/servers/channel';
import {truncateCrtRelatedTables} from '@queries/servers/entry';
import {querySavedPostsPreferences} from '@queries/servers/preference';
import {getCurrentUserId} from '@queries/servers/system';
import EphemeralStore from '@store/ephemeral_store';
import {getUserIdFromChannelName} from '@utils/user';
import {forceLogoutIfNecessary} from './session';
@@ -185,3 +190,11 @@ export const savePreferredSkinTone = async (serverUrl: string, skinCode: string)
return {error};
}
};
export const handleCRTToggled = async (serverUrl: string) => {
const currentServerUrl = await DatabaseManager.getActiveServerUrl();
await truncateCrtRelatedTables(serverUrl);
await handleReconnect(serverUrl);
EphemeralStore.setEnablingCRT(false);
DeviceEventEmitter.emit(Events.CRT_TOGGLED, serverUrl === currentServerUrl);
};

View File

@@ -7,16 +7,14 @@ import {DeviceEventEmitter, Platform} from 'react-native';
import {Database, Events} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getServerCredentials} from '@init/credentials';
import PushNotifications from '@init/push_notifications';
import NetworkManager from '@managers/network_manager';
import WebsocketManager from '@managers/websocket_manager';
import {getDeviceToken} from '@queries/app/global';
import {getServerDisplayName} from '@queries/app/servers';
import {getCurrentUserId, getExpiredSession, getConfig, getLicense} from '@queries/servers/system';
import {getCurrentUserId, getExpiredSession} from '@queries/servers/system';
import {getCurrentUser} from '@queries/servers/user';
import EphemeralStore from '@store/ephemeral_store';
import {logWarning, logError} from '@utils/log';
import {logWarning, logError, logDebug} from '@utils/log';
import {scheduleExpiredNotification} from '@utils/notification';
import {getCSRFFromCookie} from '@utils/security';
@@ -27,47 +25,25 @@ import type {LoginArgs} from '@typings/database/database';
const HTTP_UNAUTHORIZED = 401;
export const completeLogin = async (serverUrl: string) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
export const addPushProxyVerificationStateFromLogin = async (serverUrl: string) => {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const systems: IdValue[] = [];
// Set push proxy verification
const ppVerification = EphemeralStore.getPushProxyVerificationState(serverUrl);
if (ppVerification) {
systems.push({id: SYSTEM_IDENTIFIERS.PUSH_VERIFICATION_STATUS, value: ppVerification});
}
if (systems.length) {
await operator.handleSystem({systems, prepareRecordsOnly: false});
}
} catch (error) {
logDebug('error setting the push proxy verification state on login', error);
}
const {database} = operator;
const license = await getLicense(database);
const config = await getConfig(database);
if (!Object.keys(config)?.length || !license || !Object.keys(license)?.length) {
return null;
}
await DatabaseManager.setActiveServerDatabase(serverUrl);
const systems: IdValue[] = [];
// Set push proxy verification
const ppVerification = EphemeralStore.getPushProxyVerificationState(serverUrl);
if (ppVerification) {
systems.push({id: SYSTEM_IDENTIFIERS.PUSH_VERIFICATION_STATUS, value: ppVerification});
}
// Start websocket
const credentials = await getServerCredentials(serverUrl);
if (credentials?.token) {
WebsocketManager.createClient(serverUrl, credentials.token);
systems.push({
id: SYSTEM_IDENTIFIERS.WEBSOCKET,
value: 0,
});
}
if (systems.length) {
operator.handleSystem({systems, prepareRecordsOnly: false});
}
return null;
};
export const forceLogoutIfNecessary = async (serverUrl: string, err: ClientErrorProps) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
@@ -151,11 +127,12 @@ export const login = async (serverUrl: string, {ldapOnly = false, loginId, mfaTo
}
try {
const {error, hasTeams, time} = await loginEntry({serverUrl, user});
completeLogin(serverUrl);
return {error: error as ClientError, failed: false, hasTeams, time};
await addPushProxyVerificationStateFromLogin(serverUrl);
const {error} = await loginEntry({serverUrl});
await DatabaseManager.setActiveServerDatabase(serverUrl);
return {error: error as ClientError, failed: false};
} catch (error) {
return {error: error as ClientError, failed: false, time: 0};
return {error: error as ClientError, failed: false};
}
};
@@ -251,7 +228,6 @@ export const sendPasswordResetEmail = async (serverUrl: string, email: string) =
};
export const ssoLogin = async (serverUrl: string, serverDisplayName: string, serverIdentifier: string, bearerToken: string, csrfToken: string): Promise<LoginActionResponse> => {
let deviceToken;
let user;
const database = DatabaseManager.appDatabase?.database;
@@ -279,7 +255,6 @@ export const ssoLogin = async (serverUrl: string, serverDisplayName: string, ser
displayName: serverDisplayName,
},
});
deviceToken = await getDeviceToken();
user = await client.getMe();
await server?.operator.handleUsers({users: [user], prepareRecordsOnly: false});
await server?.operator.handleSystem({
@@ -294,11 +269,12 @@ export const ssoLogin = async (serverUrl: string, serverDisplayName: string, ser
}
try {
const {error, hasTeams, time} = await loginEntry({serverUrl, user, deviceToken});
completeLogin(serverUrl);
return {error: error as ClientError, failed: false, hasTeams, time};
await addPushProxyVerificationStateFromLogin(serverUrl);
const {error} = await loginEntry({serverUrl});
await DatabaseManager.setActiveServerDatabase(serverUrl);
return {error: error as ClientError, failed: false};
} catch (error) {
return {error: error as ClientError, failed: false, time: 0};
return {error: error as ClientError, failed: false};
}
};

View File

@@ -2,11 +2,12 @@
// See LICENSE.txt for license information.
import {markChannelAsViewed} from '@actions/local/channel';
import {dataRetentionCleanup} from '@actions/local/systems';
import {markChannelAsRead} from '@actions/remote/channel';
import {handleEntryAfterLoadNavigation} from '@actions/remote/entry/common';
import {handleEntryAfterLoadNavigation, registerDeviceToken} from '@actions/remote/entry/common';
import {deferredAppEntryActions, entry} from '@actions/remote/entry/gql_common';
import {fetchPostsForChannel, fetchPostThread} from '@actions/remote/post';
import {fetchStatusByIds} from '@actions/remote/user';
import {autoUpdateTimezone} from '@actions/remote/user';
import {loadConfigAndCalls} from '@calls/actions/calls';
import {
handleCallChannelDisabled,
@@ -32,17 +33,15 @@ import {Screens, WebsocketEvents} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import AppsManager from '@managers/apps_manager';
import {getCurrentChannel} from '@queries/servers/channel';
import {getLastPostInThread} from '@queries/servers/post';
import {
getConfig,
getCurrentChannelId,
getCurrentUserId,
getCurrentTeamId,
getLicense,
getWebSocketLastDisconnected,
resetWebSocketLastDisconnected,
} from '@queries/servers/system';
import {getCurrentTeam} from '@queries/servers/team';
import {getIsCRTEnabled} from '@queries/servers/thread';
import {getCurrentUser} from '@queries/servers/user';
import EphemeralStore from '@store/ephemeral_store';
@@ -72,36 +71,14 @@ import {handleLeaveTeamEvent, handleUserAddedToTeamEvent, handleUpdateTeamEvent,
import {handleThreadUpdatedEvent, handleThreadReadChangedEvent, handleThreadFollowChangedEvent} from './threads';
import {handleUserUpdatedEvent, handleUserTypingEvent} from './users';
// ESR: 5.37
const alreadyConnected = new Set<string>();
export async function handleFirstConnect(serverUrl: string) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return;
}
const {database} = operator;
const config = await getConfig(database);
const lastDisconnect = await getWebSocketLastDisconnected(database);
// ESR: 5.37
if (lastDisconnect && config?.EnableReliableWebSockets !== 'true' && alreadyConnected.has(serverUrl)) {
await handleReconnect(serverUrl);
return;
}
alreadyConnected.add(serverUrl);
resetWebSocketLastDisconnected(operator);
fetchStatusByIds(serverUrl, ['me']);
if (isSupportedServerCalls(config?.Version)) {
const currentUserId = await getCurrentUserId(database);
loadConfigAndCalls(serverUrl, currentUserId);
}
registerDeviceToken(serverUrl);
autoUpdateTimezone(serverUrl);
return doReconnect(serverUrl);
}
export async function handleReconnect(serverUrl: string) {
await doReconnect(serverUrl);
return doReconnect(serverUrl);
}
export async function handleClose(serverUrl: string, lastDisconnect: number) {
@@ -123,12 +100,12 @@ export async function handleClose(serverUrl: string, lastDisconnect: number) {
async function doReconnect(serverUrl: string) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return;
return new Error('cannot find server database');
}
const appDatabase = DatabaseManager.appDatabase?.database;
if (!appDatabase) {
return;
return new Error('cannot find app database');
}
const {database} = operator;
@@ -136,21 +113,30 @@ async function doReconnect(serverUrl: string) {
const lastDisconnectedAt = await getWebSocketLastDisconnected(database);
resetWebSocketLastDisconnected(operator);
const currentTeam = await getCurrentTeam(database);
const currentChannel = await getCurrentChannel(database);
const currentTeamId = await getCurrentTeamId(database);
const currentChannelId = await getCurrentChannelId(database);
setTeamLoading(serverUrl, true);
const entryData = await entry(serverUrl, currentTeam?.id, currentChannel?.id, lastDisconnectedAt);
const entryData = await entry(serverUrl, currentTeamId, currentChannelId, lastDisconnectedAt);
if ('error' in entryData) {
setTeamLoading(serverUrl, false);
return;
return entryData.error;
}
const {models, initialTeamId, initialChannelId, prefData, teamData, chData} = entryData;
await handleEntryAfterLoadNavigation(serverUrl, teamData.memberships || [], chData?.memberships || [], currentTeam?.id || '', currentChannel?.id || '', initialTeamId, initialChannelId);
await handleEntryAfterLoadNavigation(serverUrl, teamData.memberships || [], chData?.memberships || [], currentTeamId || '', currentChannelId || '', initialTeamId, initialChannelId);
const dt = Date.now();
await operator.batchRecords(models, 'doReconnect');
if (models?.length) {
await operator.batchRecords(models, 'doReconnect');
}
const tabletDevice = await isTablet();
if (tabletDevice && initialChannelId === currentChannelId) {
await markChannelAsRead(serverUrl, initialChannelId);
markChannelAsViewed(serverUrl, initialChannelId);
}
logInfo('WEBSOCKET RECONNECT MODELS BATCHING TOOK', `${Date.now() - dt}ms`);
setTeamLoading(serverUrl, false);
@@ -166,7 +152,10 @@ async function doReconnect(serverUrl: string) {
await deferredAppEntryActions(serverUrl, lastDisconnectedAt, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, initialTeamId);
dataRetentionCleanup(serverUrl);
AppsManager.refreshAppBindings(serverUrl);
return undefined;
}
export async function handleEvent(serverUrl: string, msg: WebSocketMessage) {

View File

@@ -1,48 +1,32 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DeviceEventEmitter} from 'react-native';
import {updateDmGmDisplayName} from '@actions/local/channel';
import {appEntry} from '@actions/remote/entry';
import {fetchPostById} from '@actions/remote/post';
import {Events, Preferences} from '@constants';
import {handleCRTToggled} from '@actions/remote/preference';
import {Preferences} from '@constants';
import DatabaseManager from '@database/manager';
import {truncateCrtRelatedTables} from '@queries/servers/entry';
import {getPostById} from '@queries/servers/post';
import {deletePreferences, differsFromLocalNameFormat, getHasCRTChanged} from '@queries/servers/preference';
async function handleCRTToggled(serverUrl: string) {
const currentServerUrl = await DatabaseManager.getActiveServerUrl();
await truncateCrtRelatedTables(serverUrl);
appEntry(serverUrl);
DeviceEventEmitter.emit(Events.CRT_TOGGLED, serverUrl === currentServerUrl);
}
import EphemeralStore from '@store/ephemeral_store';
export async function handlePreferenceChangedEvent(serverUrl: string, msg: WebSocketMessage): Promise<void> {
let database;
let operator;
try {
const result = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
database = result.database;
operator = result.operator;
} catch (e) {
if (EphemeralStore.isEnablingCRT()) {
return;
}
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const preference: PreferenceType = JSON.parse(msg.data.preference);
handleSavePostAdded(serverUrl, [preference]);
const hasDiffNameFormatPref = await differsFromLocalNameFormat(database, [preference]);
const crtToggled = await getHasCRTChanged(database, [preference]);
if (operator) {
await operator.handlePreferences({
prepareRecordsOnly: false,
preferences: [preference],
});
}
await operator.handlePreferences({
prepareRecordsOnly: false,
preferences: [preference],
});
if (hasDiffNameFormatPref) {
updateDmGmDisplayName(serverUrl);
@@ -57,22 +41,22 @@ export async function handlePreferenceChangedEvent(serverUrl: string, msg: WebSo
}
export async function handlePreferencesChangedEvent(serverUrl: string, msg: WebSocketMessage): Promise<void> {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
if (EphemeralStore.isEnablingCRT()) {
return;
}
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const preferences: PreferenceType[] = JSON.parse(msg.data.preferences);
handleSavePostAdded(serverUrl, preferences);
const hasDiffNameFormatPref = await differsFromLocalNameFormat(operator.database, preferences);
const crtToggled = await getHasCRTChanged(operator.database, preferences);
if (operator) {
await operator.handlePreferences({
prepareRecordsOnly: false,
preferences,
});
}
const hasDiffNameFormatPref = await differsFromLocalNameFormat(database, preferences);
const crtToggled = await getHasCRTChanged(database, preferences);
await operator.handlePreferences({
prepareRecordsOnly: false,
preferences,
});
if (hasDiffNameFormatPref) {
updateDmGmDisplayName(serverUrl);
@@ -87,14 +71,10 @@ export async function handlePreferencesChangedEvent(serverUrl: string, msg: WebS
}
export async function handlePreferencesDeletedEvent(serverUrl: string, msg: WebSocketMessage): Promise<void> {
const database = DatabaseManager.serverDatabases[serverUrl];
if (!database) {
return;
}
try {
const databaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const preferences: PreferenceType[] = JSON.parse(msg.data.preferences);
deletePreferences(database, preferences);
deletePreferences(databaseAndOperator, preferences);
} catch {
// Do nothing
}
@@ -102,16 +82,17 @@ export async function handlePreferencesDeletedEvent(serverUrl: string, msg: WebS
// If preferences include new save posts we fetch them
async function handleSavePostAdded(serverUrl: string, preferences: PreferenceType[]) {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return;
}
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const savedPosts = preferences.filter((p) => p.category === Preferences.CATEGORIES.SAVED_POST);
const savedPosts = preferences.filter((p) => p.category === Preferences.CATEGORIES.SAVED_POST);
for await (const saved of savedPosts) {
const post = await getPostById(database, saved.name);
if (!post) {
await fetchPostById(serverUrl, saved.name, false);
for await (const saved of savedPosts) {
const post = await getPostById(database, saved.name);
if (!post) {
await fetchPostById(serverUrl, saved.name, false);
}
}
} catch {
// Do nothing
}
}

View File

@@ -2,8 +2,8 @@
// See LICENSE.txt for license information.
import {markTeamThreadsAsRead, processReceivedThreads, updateThread} from '@actions/local/thread';
import {getCurrentTeamId} from '@app/queries/servers/system';
import DatabaseManager from '@database/manager';
import {getCurrentTeamId} from '@queries/servers/system';
import EphemeralStore from '@store/ephemeral_store';
export async function handleThreadUpdatedEvent(serverUrl: string, msg: WebSocketMessage): Promise<void> {

View File

@@ -6,17 +6,19 @@ import {Platform} from 'react-native';
import {WebsocketEvents} from '@constants';
import DatabaseManager from '@database/manager';
import {getConfig} from '@queries/servers/system';
import {getConfigValue} from '@queries/servers/system';
import {hasReliableWebsocket} from '@utils/config';
import {toMilliseconds} from '@utils/datetime';
import {logError, logInfo, logWarning} from '@utils/log';
const MAX_WEBSOCKET_FAILS = 7;
const MIN_WEBSOCKET_RETRY_TIME = 3000; // 3 sec
const MAX_WEBSOCKET_RETRY_TIME = 300000; // 5 mins
const WEBSOCKET_TIMEOUT = toMilliseconds({seconds: 30});
const MIN_WEBSOCKET_RETRY_TIME = toMilliseconds({seconds: 3});
const MAX_WEBSOCKET_RETRY_TIME = toMilliseconds({minutes: 5});
export default class WebSocketClient {
private conn?: WebSocketClientInterface;
private connectionTimeout: any;
private connectionTimeout: NodeJS.Timeout | undefined;
private connectionId: string;
private token: string;
@@ -43,6 +45,7 @@ export default class WebSocketClient {
private url = '';
private serverUrl: string;
private hasReliablyReconnect = false;
constructor(serverUrl: string, token: string, lastDisconnect = 0) {
this.connectionId = '';
@@ -76,8 +79,12 @@ export default class WebSocketClient {
return;
}
const config = await getConfig(database);
const connectionUrl = (config.WebsocketURL || this.serverUrl) + '/api/v4/websocket';
const [websocketUrl, version, reliableWebsocketConfig] = await Promise.all([
getConfigValue(database, 'WebsocketURL'),
getConfigValue(database, 'Version'),
getConfigValue(database, 'EnableReliableWebSockets'),
]);
const connectionUrl = (websocketUrl || this.serverUrl) + '/api/v4/websocket';
if (this.connectingCallback) {
this.connectingCallback();
@@ -98,7 +105,7 @@ export default class WebSocketClient {
this.url = connectionUrl;
const reliableWebSockets = config.EnableReliableWebSockets === 'true';
const reliableWebSockets = hasReliableWebsocket(version, reliableWebsocketConfig);
if (reliableWebSockets) {
// Add connection id, and last_sequence_number to the query param.
// We cannot also send it as part of the auth_challenge, because the session cookie is already sent with the request.
@@ -125,7 +132,12 @@ export default class WebSocketClient {
// iOS is using he underlying cookieJar
headers.Authorization = `Bearer ${this.token}`;
}
const {client} = await getOrCreateWebSocketClient(this.url, {headers});
const {client} = await getOrCreateWebSocketClient(this.url, {headers, timeoutInterval: WEBSOCKET_TIMEOUT});
// Check again if the client is the same, to avoid race conditions
if (this.conn === client) {
return;
}
this.conn = client;
} catch (error) {
return;
@@ -154,6 +166,7 @@ export default class WebSocketClient {
if (this.serverSequence && this.missedEventsCallback) {
this.missedEventsCallback();
}
this.hasReliablyReconnect = true;
}
} else if (this.firstConnectCallback) {
logInfo('websocket connected to', this.url);
@@ -171,6 +184,7 @@ export default class WebSocketClient {
this.conn = undefined;
this.responseSequence = 1;
this.hasReliablyReconnect = false;
if (this.connectFailCount === 0) {
logInfo('websocket closed', this.url);
@@ -203,7 +217,9 @@ export default class WebSocketClient {
this.connectionTimeout = setTimeout(
() => {
if (this.stop) {
clearTimeout(this.connectionTimeout);
if (this.connectionTimeout) {
clearTimeout(this.connectionTimeout);
}
return;
}
this.initialize(opts);
@@ -214,6 +230,7 @@ export default class WebSocketClient {
this.conn!.onError((evt: any) => {
if (evt.url === this.url) {
this.hasReliablyReconnect = false;
if (this.connectFailCount <= 1) {
logError('websocket error', this.url);
logError('WEBSOCKET ERROR EVENT', evt);
@@ -243,11 +260,17 @@ export default class WebSocketClient {
// If we already have a connectionId present, and server sends a different one,
// that means it's either a long timeout, or server restart, or sequence number is not found.
// If the server is not available the first time we try to connect, we won't have a connection id
// but still we need to sync.
// Then we do the sync calls, and reset sequence number to 0.
if (this.connectionId !== '' && this.connectionId !== msg.data.connection_id) {
logInfo(this.url, 'long timeout, or server restart, or sequence number is not found.');
if (
(this.connectionId !== '' && this.connectionId !== msg.data.connection_id) ||
(this.hasReliablyReconnect && this.connectionId === '')
) {
logInfo(this.url, 'long timeout, or server restart, or sequence number is not found, or first connect after failure.');
this.reconnectCallback();
this.serverSequence = 0;
this.hasReliablyReconnect = false;
}
// If it's a fresh connection, we have to set the connectionId regardless.
@@ -315,6 +338,7 @@ export default class WebSocketClient {
this.stop = stop;
this.connectFailCount = 0;
this.responseSequence = 1;
this.hasReliablyReconnect = false;
if (this.conn && this.conn.readyState === WebSocketReadyState.OPEN) {
this.conn.close();

View File

@@ -1,88 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleProp, TextStyle, View, ViewStyle} from 'react-native';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import type {MessageDescriptor} from '@formatjs/intl/src/types';
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
marginBottom: 30,
},
header: {
marginHorizontal: 15,
marginBottom: 10,
fontSize: 13,
color: changeOpacity(theme.centerChannelColor, 0.5),
},
footer: {
marginTop: 10,
marginHorizontal: 15,
fontSize: 12,
color: changeOpacity(theme.centerChannelColor, 0.5),
},
};
});
export type SectionText = {
id: string;
defaultMessage: string;
values?: MessageDescriptor;
}
export type BlockProps = {
children: React.ReactNode;
disableFooter?: boolean;
disableHeader?: boolean;
footerText?: SectionText;
headerText?: SectionText;
containerStyles?: StyleProp<ViewStyle>;
headerStyles?: StyleProp<TextStyle>;
footerStyles?: StyleProp<TextStyle>;
}
const Block = ({
children,
containerStyles,
disableFooter,
disableHeader,
footerText,
headerStyles,
headerText,
footerStyles,
}: BlockProps) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
return (
<View style={styles.container}>
{(headerText && !disableHeader) &&
<FormattedText
defaultMessage={headerText.defaultMessage}
id={headerText.id}
values={headerText.values}
style={[styles.header, headerStyles]}
/>
}
<View style={containerStyles}>
{children}
</View>
{(footerText && !disableFooter) &&
<FormattedText
defaultMessage={footerText.defaultMessage}
id={footerText.id}
style={[styles.footer, footerStyles]}
values={footerText.values}
/>
}
</View>
);
};
export default Block;

View File

@@ -2,12 +2,15 @@
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {StyleProp, Text, TextStyle, ViewStyle} from 'react-native';
import {StyleProp, StyleSheet, Text, TextStyle, View, ViewStyle} from 'react-native';
import RNButton from 'react-native-button';
import CompassIcon from '@components/compass_icon';
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
type Props = {
type ConditionalProps = | {iconName: string; iconSize: number} | {iconName?: never; iconSize?: never}
type Props = ConditionalProps & {
theme: Theme;
backgroundStyle?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
@@ -20,6 +23,11 @@ type Props = {
text: string;
}
const styles = StyleSheet.create({
container: {flexDirection: 'row'},
icon: {marginRight: 7},
});
const Button = ({
theme,
backgroundStyle,
@@ -31,6 +39,8 @@ const Button = ({
onPress,
text,
testID,
iconName,
iconSize,
}: Props) => {
const bgStyle = useMemo(() => [
buttonBackgroundStyle(theme, size, emphasis, buttonType, buttonState),
@@ -48,12 +58,22 @@ const Button = ({
onPress={onPress}
testID={testID}
>
<Text
style={txtStyle}
numberOfLines={1}
>
{text}
</Text>
<View style={styles.container}>
{Boolean(iconName) &&
<CompassIcon
name={iconName!}
size={iconSize}
color={StyleSheet.flatten(txtStyle).color}
style={styles.icon}
/>
}
<Text
style={txtStyle}
numberOfLines={1}
>
{text}
</Text>
</View>
</RNButton>
);
};

View File

@@ -10,7 +10,7 @@ import {switchMap, distinctUntilChanged} from 'rxjs/operators';
import {observeChannelsWithCalls} from '@calls/state';
import {General} from '@constants';
import {withServerUrl} from '@context/server';
import {observeChannelSettings, observeMyChannel, queryChannelMembers} from '@queries/servers/channel';
import {observeIsMutedSetting, observeMyChannel, queryChannelMembers} from '@queries/servers/channel';
import {queryDraft} from '@queries/servers/drafts';
import {observeCurrentChannelId, observeCurrentUserId} from '@queries/servers/system';
import {observeTeam} from '@queries/servers/team';
@@ -19,7 +19,6 @@ import ChannelItem from './channel_item';
import type {WithDatabaseArgs} from '@typings/database/database';
import type ChannelModel from '@typings/database/models/servers/channel';
import type MyChannelModel from '@typings/database/models/servers/my_channel';
type EnhanceProps = WithDatabaseArgs & {
channel: ChannelModel;
@@ -27,8 +26,6 @@ type EnhanceProps = WithDatabaseArgs & {
serverUrl?: string;
}
const observeIsMutedSetting = (mc: MyChannelModel) => observeChannelSettings(mc.database, mc.id).pipe(switchMap((s) => of$(s?.notifyProps?.mark_unread === General.MENTION)));
const enhance = withObservables(['channel', 'showTeamName'], ({
channel,
database,
@@ -53,7 +50,7 @@ const enhance = withObservables(['channel', 'showTeamName'], ({
if (!mc) {
return of$(false);
}
return observeIsMutedSetting(mc);
return observeIsMutedSetting(database, mc.id);
}),
);

View File

@@ -17,6 +17,7 @@ import {Events, Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {useInputPropagation} from '@hooks/input';
import {t} from '@i18n';
import NavigationStore from '@store/navigation_store';
import {extractFileInfo} from '@utils/file';
@@ -120,6 +121,7 @@ export default function PostInput({
const style = getStyleSheet(theme);
const serverUrl = useServerUrl();
const managedConfig = useManagedConfig<ManagedConfig>();
const [propagateValue, shouldProcessEvent] = useInputPropagation();
const lastTypingEventSent = useRef(0);
@@ -180,6 +182,9 @@ export default function PostInput({
}, [updateCursorPosition, cursorPosition]);
const handleTextChange = useCallback((newValue: string) => {
if (!shouldProcessEvent(newValue)) {
return;
}
updateValue(newValue);
lastNativeValue.current = newValue;
@@ -224,10 +229,16 @@ export default function PostInput({
case 'enter':
sendMessage();
break;
case 'shift-enter':
updateValue((v) => v.substring(0, cursorPosition) + '\n' + v.substring(cursorPosition));
case 'shift-enter': {
let newValue: string;
updateValue((v) => {
newValue = v.substring(0, cursorPosition) + '\n' + v.substring(cursorPosition);
return newValue;
});
updateCursorPosition((pos) => pos + 1);
propagateValue(newValue!);
break;
}
}
}
}, [sendMessage, updateValue, cursorPosition, isTablet]);
@@ -266,18 +277,19 @@ export default function PostInput({
const draft = value ? `${value} ${text} ` : `${text} `;
updateValue(draft);
updateCursorPosition(draft.length);
propagateValue(draft);
inputRef.current?.focus();
}
});
return () => listener.remove();
}, [updateValue, value, channelId, rootId]);
return () => {
listener.remove();
updateDraftMessage(serverUrl, channelId, rootId, lastNativeValue.current); // safe draft on unmount
};
}, [updateValue, channelId, rootId]);
useEffect(() => {
if (value !== lastNativeValue.current) {
// May change when we implement Fabric
inputRef.current?.setNativeProps({
text: value,
});
propagateValue(value);
lastNativeValue.current = value;
}
}, [value]);
@@ -310,6 +322,7 @@ export default function PostInput({
testID={testID}
underlineColorAndroid='transparent'
textContentType='none'
value={value}
/>
);
}

View File

@@ -108,7 +108,7 @@ const CombinedUserActivity = ({
return;
}
const passProps = {post};
const passProps = {post, sourceScreen: location};
Keyboard.dismiss();
const title = isTablet ? intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}) : '';
@@ -117,7 +117,7 @@ const CombinedUserActivity = ({
} else {
showModalOverCurrentContext(Screens.POST_OPTIONS, passProps, bottomSheetModalOptions(theme));
}
}, [post, canDelete, isTablet, intl]);
}, [post, canDelete, isTablet, intl, location]);
const renderMessage = (postType: string, userIds: string[], actorId: string) => {
let actor = '';

View File

@@ -19,21 +19,25 @@ import PostList from './post_list';
import type {WithDatabaseArgs} from '@typings/database/database';
import type PostModel from '@typings/database/models/servers/post';
const enhanced = withObservables(['posts'], ({database, posts}: {posts: PostModel[]} & WithDatabaseArgs) => {
const enhancedWithoutPosts = withObservables([], ({database}: WithDatabaseArgs) => {
const currentUser = observeCurrentUser(database);
const postIds = posts.map((p) => p.id);
return {
appsEnabled: observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'),
isTimezoneEnabled: observeConfigBooleanValue(database, 'ExperimentalTimezone'),
currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user?.timezone || null))))),
currentUserId: currentUser.pipe((switchMap((user) => of$(user?.id)))),
currentUsername: currentUser.pipe((switchMap((user) => of$(user?.username)))),
savedPostIds: observeSavedPostsByIds(database, postIds),
customEmojiNames: queryAllCustomEmojis(database).observe().pipe(
customEmojiNames: queryAllCustomEmojis(database).observeWithColumns(['name']).pipe(
switchMap((customEmojis) => of$(mapCustomEmojiNames(customEmojis))),
),
};
});
export default React.memo(withDatabase(enhanced(PostList)));
const enhanced = withObservables(['posts'], ({database, posts}: {posts: PostModel[]} & WithDatabaseArgs) => {
const postIds = posts.map((p) => p.id);
return {
savedPostIds: observeSavedPostsByIds(database, postIds),
};
});
export default React.memo(withDatabase(enhancedWithoutPosts(enhanced(PostList))));

View File

@@ -3,8 +3,9 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React from 'react';
import {of as of$, first as first$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import {observeMyChannel} from '@queries/servers/channel';
import {observeThreadById} from '@queries/servers/thread';
@@ -31,8 +32,14 @@ const enhanced = withObservables(['channelId', 'isCRTEnabled', 'rootId'], ({chan
}
const myChannel = observeMyChannel(database, channelId);
const isManualUnread = myChannel.pipe(switchMap((ch) => of$(ch?.manuallyUnread)));
const unreadCount = myChannel.pipe(switchMap((ch) => of$(ch?.messageCount)));
const isManualUnread = myChannel.pipe(
switchMap((ch) => of$(ch?.manuallyUnread)),
distinctUntilChanged(),
);
const unreadCount = myChannel.pipe(
switchMap((ch) => of$(ch?.messageCount)),
distinctUntilChanged(),
);
return {
isManualUnread,
@@ -40,4 +47,4 @@ const enhanced = withObservables(['channelId', 'isCRTEnabled', 'rootId'], ({chan
};
});
export default withDatabase(enhanced(MoreMessages));
export default React.memo(withDatabase(enhanced(MoreMessages)));

View File

@@ -86,11 +86,11 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
if (reaction) {
const emojiAlias = getEmojiFirstAlias(reaction.emojiName);
if (acc.has(emojiAlias)) {
const rs = acc.get(emojiAlias);
const rs = acc.get(emojiAlias)!;
// eslint-disable-next-line max-nested-callbacks
const present = rs!.findIndex((r) => r.userId === reaction.userId) > -1;
const present = rs.findIndex((r) => r.userId === reaction.userId) > -1;
if (!present) {
rs!.push(reaction);
rs.push(reaction);
}
} else {
acc.set(emojiAlias, [reaction]);
@@ -105,7 +105,7 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
}, new Map<string, ReactionModel[]>());
return {reactionsByName, highlightedReactions};
}, [sortedReactions]);
}, [sortedReactions, reactions]);
const handleAddReactionToPost = (emoji: string) => {
addReaction(serverUrl, postId, emoji);
@@ -178,7 +178,7 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
return (
<Reaction
key={r}
count={reaction!.length}
count={reaction?.length || 1}
emojiName={r}
highlight={highlightedReactions.includes(r)}
onPress={handleReactionPress}

View File

@@ -150,6 +150,7 @@ export default function SelectedUsers({
const [isVisible, setIsVisible] = useState(false);
const numberSelectedIds = Object.keys(selectedIds).length;
const bottomSpace = (dimensions.height - containerHeight - modalPosition);
const bottomPaddingBottom = isTablet ? CHIP_HEIGHT_WITH_MARGIN : 0;
const users = useMemo(() => {
const u = [];
@@ -172,8 +173,8 @@ export default function SelectedUsers({
}, [selectedIds, teammateNameDisplay, onRemove]);
const totalPanelHeight = useDerivedValue(() => (
isVisible ? panelHeight.value + BUTTON_HEIGHT : 0
), [isVisible, isTablet]);
isVisible ? panelHeight.value + BUTTON_HEIGHT + bottomPaddingBottom : 0
), [isVisible, isTablet, bottomPaddingBottom]);
const marginBottom = useMemo(() => {
let margin = keyboard.height && Platform.OS === 'ios' ? keyboard.height - insets.bottom : 0;
@@ -208,7 +209,7 @@ export default function SelectedUsers({
}, [onPress]);
const onLayout = useCallback((e: LayoutChangeEvent) => {
panelHeight.value = Math.min(PANEL_MAX_HEIGHT, e.nativeEvent.layout.height);
panelHeight.value = Math.min(PANEL_MAX_HEIGHT + bottomPaddingBottom, e.nativeEvent.layout.height);
}, []);
const androidMaxHeight = Platform.select({
@@ -235,8 +236,8 @@ export default function SelectedUsers({
const animatedViewStyle = useAnimatedStyle(() => ({
height: withTiming(totalPanelHeight.value + insets.bottom, {duration: 250}),
borderWidth: isVisible ? 1 : 0,
maxHeight: isVisible ? PANEL_MAX_HEIGHT + BUTTON_HEIGHT + insets.bottom : 0,
}), [isVisible, insets]);
maxHeight: isVisible ? PANEL_MAX_HEIGHT + BUTTON_HEIGHT + bottomPaddingBottom + insets.bottom : 0,
}), [isVisible, insets, bottomPaddingBottom]);
const animatedButtonStyle = useAnimatedStyle(() => ({
opacity: withTiming(isVisible ? 1 : 0, {duration: isVisible ? 500 : 100}),

View File

@@ -0,0 +1,91 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {LayoutChangeEvent, StyleProp, TextStyle, View, ViewStyle} from 'react-native';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type {MessageDescriptor} from 'react-intl';
type SectionText = {
id: string;
defaultMessage: string;
values?: MessageDescriptor;
}
type SettingBlockProps = {
children: React.ReactNode;
containerStyles?: StyleProp<ViewStyle>;
disableFooter?: boolean;
disableHeader?: boolean;
footerStyles?: StyleProp<TextStyle>;
footerText?: SectionText;
headerStyles?: StyleProp<TextStyle>;
headerText?: SectionText;
onLayout?: (event: LayoutChangeEvent) => void;
};
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
marginBottom: 30,
},
contentContainerStyle: {
marginBottom: 0,
},
header: {
color: theme.centerChannelColor,
...typography('Heading', 300, 'SemiBold'),
marginBottom: 8,
marginLeft: 20,
marginTop: 12,
marginRight: 15,
},
footer: {
marginTop: 10,
marginHorizontal: 15,
fontSize: 12,
color: changeOpacity(theme.centerChannelColor, 0.5),
},
};
});
const SettingBlock = ({
children, containerStyles, disableFooter, disableHeader,
footerStyles, footerText, headerStyles, headerText, onLayout,
}: SettingBlockProps) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
return (
<View
style={styles.container}
onLayout={onLayout}
>
{(headerText && !disableHeader) &&
<FormattedText
defaultMessage={headerText.defaultMessage}
id={headerText.id}
values={headerText.values}
style={[styles.header, headerStyles]}
/>
}
<View style={[styles.contentContainerStyle, containerStyles]}>
{children}
</View>
{(footerText && !disableFooter) &&
<FormattedText
defaultMessage={footerText.defaultMessage}
id={footerText.id}
style={[styles.footer, footerStyles]}
values={footerText.values}
/>
}
</View>
);
};
export default SettingBlock;

View File

@@ -7,11 +7,12 @@ import {Platform} from 'react-native';
import OptionItem, {OptionItemProps} from '@components/option_item';
import {useTheme} from '@context/theme';
import SettingSeparator from '@screens/settings/settings_separator';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import Options, {DisplayOptionConfig, NotificationsOptionConfig, SettingOptionConfig} from './config';
import Options, {DisplayOptionConfig, NotificationsOptionConfig, SettingOptionConfig} from '../../screens/settings/config';
import SettingSeparator from './separator';
type SettingsConfig = keyof typeof SettingOptionConfig | keyof typeof NotificationsOptionConfig| keyof typeof DisplayOptionConfig
type SettingOptionProps = {

View File

@@ -14,7 +14,7 @@ import TeamList from './team_list';
type Props = {
iconPad?: boolean;
canJoinOtherTeams: boolean;
teamsCount: number;
hasMoreThanOneTeam: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
@@ -36,8 +36,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
export default function TeamSidebar({iconPad, canJoinOtherTeams, teamsCount}: Props) {
const initialWidth = teamsCount > 1 ? TEAM_SIDEBAR_WIDTH : 0;
export default function TeamSidebar({iconPad, canJoinOtherTeams, hasMoreThanOneTeam}: Props) {
const initialWidth = hasMoreThanOneTeam ? TEAM_SIDEBAR_WIDTH : 0;
const width = useSharedValue(initialWidth);
const marginTop = useSharedValue(iconPad ? 44 : 0);
const theme = useTheme();
@@ -58,8 +58,8 @@ export default function TeamSidebar({iconPad, canJoinOtherTeams, teamsCount}: Pr
}, [iconPad]);
useEffect(() => {
width.value = teamsCount > 1 ? TEAM_SIDEBAR_WIDTH : 0;
}, [teamsCount]);
width.value = hasMoreThanOneTeam ? TEAM_SIDEBAR_WIDTH : 0;
}, [hasMoreThanOneTeam]);
return (
<Animated.View style={[styles.container, transform]}>

View File

@@ -4,12 +4,12 @@
import React, {useCallback, useMemo} from 'react';
import {StyleProp, View, ViewStyle} from 'react-native';
import {useEmojiSkinTone} from '@app/hooks/emoji_category_bar';
import {preventDoubleTap} from '@app/utils/tap';
import Emoji from '@components/emoji';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useEmojiSkinTone} from '@hooks/emoji_category_bar';
import {skinCodes} from '@utils/emoji';
import {isValidNamedEmoji} from '@utils/emoji/helpers';
import {preventDoubleTap} from '@utils/tap';
type Props = {
name: string;

View File

@@ -3,6 +3,8 @@
export const CATEGORIES_TO_KEEP: Record<string, string> = {
ADVANCED_SETTINGS: 'advanced_settings',
CHANNEL_APPROXIMATE_VIEW_TIME: 'channel_approximate_view_time',
CHANNEL_OPEN_TIME: 'channel_open_time',
DIRECT_CHANNEL_SHOW: 'direct_channel_show',
GROUP_CHANNEL_SHOW: 'group_channel_show',
DISPLAY_SETTINGS: 'display_settings',

View File

@@ -10,7 +10,7 @@ export const CALL = 'Call';
export const CHANNEL = 'Channel';
export const CHANNEL_ADD_PEOPLE = 'ChannelAddPeople';
export const CHANNEL_INFO = 'ChannelInfo';
export const CHANNEL_MENTION = 'ChannelMention';
export const CHANNEL_NOTIFICATION_PREFERENCES = 'ChannelNotificationPreferences';
export const CODE = 'Code';
export const CREATE_DIRECT_MESSAGE = 'CreateDirectMessage';
export const CREATE_OR_EDIT_CHANNEL = 'CreateOrEditChannel';
@@ -79,7 +79,7 @@ export default {
CHANNEL,
CHANNEL_ADD_PEOPLE,
CHANNEL_INFO,
CHANNEL_MENTION,
CHANNEL_NOTIFICATION_PREFERENCES,
CODE,
CREATE_DIRECT_MESSAGE,
CREATE_OR_EDIT_CHANNEL,
@@ -172,6 +172,5 @@ export const SCREENS_AS_BOTTOM_SHEET = new Set<string>([
export const NOT_READY = [
CHANNEL_ADD_PEOPLE,
CHANNEL_MENTION,
CREATE_TEAM,
];

View File

@@ -246,10 +246,11 @@ const ChannelHandler = <TBase extends Constructor<ServerDataOperatorBase>>(super
const totalMsg = isCRT ? channel.total_msg_count_root! : channel.total_msg_count;
const myMsgCount = isCRT ? my.msg_count_root! : my.msg_count;
const msgCount = Math.max(0, totalMsg - myMsgCount);
const lastPostAt = isCRT ? (channel.last_root_post_at || channel.last_post_at) : channel.last_post_at;
my.msg_count = msgCount;
my.mention_count = isCRT ? my.mention_count_root! : my.mention_count;
my.is_unread = msgCount > 0;
my.last_post_at = (isCRT ? (channel.last_root_post_at || channel.last_post_at) : channel.last_post_at) || 0;
my.last_post_at = lastPostAt;
}
}
@@ -271,7 +272,7 @@ const ChannelHandler = <TBase extends Constructor<ServerDataOperatorBase>>(super
}
const chan = channelMap[my.channel_id];
const lastPostAt = (isCRT ? chan.last_root_post_at : chan.last_post_at) || 0;
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

25
app/hooks/input.ts Normal file
View File

@@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useCallback, useRef} from 'react';
import {Platform} from 'react-native';
export function useInputPropagation(): [(v: string) => void, (v: string) => boolean] {
const waitForValue = useRef<string>();
const waitToPropagate = useCallback((value: string) => {
waitForValue.current = value;
}, []);
const shouldProcessEvent = useCallback((newValue: string) => {
if (Platform.OS === 'android') {
return true;
}
if (waitForValue.current === undefined) {
return true;
}
if (newValue === waitForValue.current) {
waitForValue.current = undefined;
}
return false;
}, []);
return [waitToPropagate, shouldProcessEvent];
}

View File

@@ -52,6 +52,6 @@ export async function start() {
registerNavigationListeners();
registerScreens();
await initialLaunch();
WebsocketManager.init(serverCredentials);
await WebsocketManager.init(serverCredentials);
initialLaunch();
}

View File

@@ -27,8 +27,7 @@ const initialNotificationTypes = [PushNotification.NOTIFICATION_TYPE.MESSAGE, Pu
export const initialLaunch = async () => {
const deepLinkUrl = await Linking.getInitialURL();
if (deepLinkUrl) {
await launchAppFromDeepLink(deepLinkUrl, true);
return;
return launchAppFromDeepLink(deepLinkUrl, true);
}
const notification = await Notifications.getInitialNotification();
@@ -43,11 +42,10 @@ export const initialLaunch = async () => {
tapped = delivered.find((d) => (d as unknown as NotificationData).ack_id === notification?.payload.ack_id) == null;
}
if (initialNotificationTypes.includes(notification?.payload?.type) && tapped) {
await launchAppFromNotification(convertToNotificationData(notification!), true);
return;
return launchAppFromNotification(convertToNotificationData(notification!), true);
}
await launchApp({launchType: Launch.Normal, coldStart: true});
return launchApp({launchType: Launch.Normal, coldStart: notification ? tapped : true});
};
const launchAppFromDeepLink = async (deepLinkUrl: string, coldStart = false) => {
@@ -162,14 +160,17 @@ const launchToHome = async (props: LaunchProps) => {
const extra = props.extra as NotificationWithData;
openPushNotification = Boolean(props.serverUrl && !props.launchError && extra.userInteraction && extra.payload?.channel_id && !extra.payload?.userInfo?.local);
if (openPushNotification) {
pushNotificationEntry(props.serverUrl!, extra);
} else {
appEntry(props.serverUrl!);
await resetToHome(props);
return pushNotificationEntry(props.serverUrl!, extra.payload!);
}
appEntry(props.serverUrl!);
break;
}
case Launch.Normal:
appEntry(props.serverUrl!);
if (props.coldStart) {
appEntry(props.serverUrl!);
}
break;
}
@@ -202,16 +203,21 @@ export const getLaunchPropsFromNotification = async (notification: NotificationW
const {payload} = notification;
launchProps.extra = notification;
let serverUrl: string | undefined;
if (payload?.server_url) {
launchProps.serverUrl = payload.server_url;
} else if (payload?.server_id) {
const serverUrl = await DatabaseManager.getServerUrlFromIdentifier(payload.server_id);
if (serverUrl) {
launchProps.serverUrl = serverUrl;
} else {
launchProps.launchError = true;
try {
if (payload?.server_url) {
DatabaseManager.getServerDatabaseAndOperator(payload.server_url);
serverUrl = payload.server_url;
} else if (payload?.server_id) {
serverUrl = await DatabaseManager.getServerUrlFromIdentifier(payload.server_id);
}
} catch {
launchProps.launchError = true;
}
if (serverUrl) {
launchProps.serverUrl = serverUrl;
} else {
launchProps.launchError = true;
}

View File

@@ -37,22 +37,22 @@ class PushNotifications {
configured = false;
init(register: boolean) {
if (register) {
this.registerIfNeeded();
}
Notifications.events().registerNotificationOpened(this.onNotificationOpened);
Notifications.events().registerRemoteNotificationsRegistered(this.onRemoteNotificationsRegistered);
Notifications.events().registerNotificationReceivedBackground(this.onNotificationReceivedBackground);
Notifications.events().registerNotificationReceivedForeground(this.onNotificationReceivedForeground);
if (register) {
this.registerIfNeeded();
}
}
async registerIfNeeded() {
const isRegistered = await Notifications.isRegisteredForRemoteNotifications();
if (!isRegistered) {
await requestNotifications(['alert', 'sound', 'badge']);
Notifications.registerRemoteNotifications();
}
Notifications.registerRemoteNotifications();
}
createReplyCategory = () => {

View File

@@ -33,6 +33,7 @@ class WebsocketManager {
private previousActiveState: boolean;
private statusUpdatesIntervalIDs: Record<string, NodeJS.Timer> = {};
private backgroundIntervalId: number | undefined;
private firstConnectionSynced: Record<string, boolean> = {};
constructor() {
this.previousActiveState = AppState.currentState === 'active';
@@ -40,21 +41,15 @@ class WebsocketManager {
public init = async (serverCredentials: ServerCredential[]) => {
this.netConnected = Boolean((await NetInfo.fetch()).isConnected);
await Promise.all(
serverCredentials.map(
async ({serverUrl, token}) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return;
}
try {
this.createClient(serverUrl, token, 0);
} catch (error) {
logError('WebsocketManager init error', error);
}
},
),
serverCredentials.forEach(
({serverUrl, token}) => {
try {
DatabaseManager.getServerDatabaseAndOperator(serverUrl);
this.createClient(serverUrl, token, 0);
} catch (error) {
logError('WebsocketManager init error', error);
}
},
);
AppState.addEventListener('change', this.onAppStateChange);
@@ -68,6 +63,7 @@ class WebsocketManager {
this.connectionTimerIDs[serverUrl].cancel();
}
delete this.clients[serverUrl];
delete this.firstConnectionSynced[serverUrl];
this.getConnectedSubject(serverUrl).next('not_connected');
delete this.connectedSubjects[serverUrl];
@@ -84,9 +80,6 @@ class WebsocketManager {
client.setReliableReconnectCallback(() => this.onReliableReconnect(serverUrl));
client.setCloseCallback((connectFailCount: number, lastDisconnect: number) => this.onWebsocketClose(serverUrl, connectFailCount, lastDisconnect));
if (this.netConnected && ['unknown', 'active'].includes(AppState.currentState)) {
client.initialize();
}
this.clients[serverUrl] = client;
return this.clients[serverUrl];
@@ -143,25 +136,34 @@ class WebsocketManager {
}
};
private initializeClient = (serverUrl: string) => {
public initializeClient = async (serverUrl: string) => {
const client: WebSocketClient = this.clients[serverUrl];
if (!client?.isConnected()) {
client.initialize();
}
this.connectionTimerIDs[serverUrl]?.cancel();
delete this.connectionTimerIDs[serverUrl];
if (!client?.isConnected()) {
client.initialize();
if (!this.firstConnectionSynced[serverUrl]) {
const error = await handleFirstConnect(serverUrl);
if (error) {
client.close(false);
}
this.firstConnectionSynced[serverUrl] = true;
}
}
};
private onFirstConnect = (serverUrl: string) => {
this.startPeriodicStatusUpdates(serverUrl);
this.getConnectedSubject(serverUrl).next('connected');
handleFirstConnect(serverUrl);
};
private onReconnect = async (serverUrl: string) => {
this.startPeriodicStatusUpdates(serverUrl);
this.getConnectedSubject(serverUrl).next('connected');
await handleReconnect(serverUrl);
const error = await handleReconnect(serverUrl);
if (error) {
this.getClient(serverUrl)?.close(false);
}
};
private onReliableReconnect = async (serverUrl: string) => {

View File

@@ -234,8 +234,10 @@ export async function newConnection(
});
peer.on('stream', (remoteStream: MediaStream) => {
logDebug('new remote stream received', remoteStream);
logDebug('remote tracks', remoteStream.getTracks());
logDebug('new remote stream received', remoteStream.id);
for (const track of remoteStream.getTracks()) {
logDebug('remote track', track.id);
}
streams.push(remoteStream);
if (remoteStream.getVideoTracks().length > 0) {

View File

@@ -18,7 +18,6 @@ import {Navigation} from 'react-native-navigation';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {RTCView} from 'react-native-webrtc';
import {appEntry} from '@actions/remote/entry';
import {leaveCall, muteMyself, setSpeakerphoneOn, unmuteMyself} from '@calls/actions';
import {startCallRecording, stopCallRecording} from '@calls/actions/calls';
import {recordingAlert, recordingWillBePostedAlert, recordingErrorAlert} from '@calls/alerts';
@@ -41,6 +40,7 @@ import {useTheme} from '@context/theme';
import DatabaseManager from '@database/manager';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useIsTablet} from '@hooks/device';
import WebsocketManager from '@managers/websocket_manager';
import {
bottomSheet,
dismissAllModalsAndPopToScreen,
@@ -357,7 +357,7 @@ const CallScreen = ({
await popTopScreen(Screens.THREAD);
}
await DatabaseManager.setActiveServerDatabase(currentCall.serverUrl);
await appEntry(currentCall.serverUrl, Date.now());
WebsocketManager.initializeClient(currentCall.serverUrl);
await goToScreen(Screens.THREAD, callThreadOptionTitle, {rootId: currentCall.threadId});
}, [currentCall?.serverUrl, currentCall?.threadId, fromThreadScreen, componentId, callThreadOptionTitle]);

View File

@@ -11,14 +11,11 @@ import {makeCategoryChannelId} from '@utils/categories';
import {pluckUnique} from '@utils/helpers';
import {logDebug} from '@utils/log';
import {observeChannelsByLastPostAt} from './channel';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type CategoryModel from '@typings/database/models/servers/category';
import type CategoryChannelModel from '@typings/database/models/servers/category_channel';
import type ChannelModel from '@typings/database/models/servers/channel';
const {SERVER: {CATEGORY, CATEGORY_CHANNEL, CHANNEL}} = MM_TABLES;
const {SERVER: {CATEGORY, CATEGORY_CHANNEL}} = MM_TABLES;
export const getCategoryById = async (database: Database, categoryId: string) => {
try {
@@ -144,24 +141,3 @@ export const observeIsChannelFavorited = (database: Database, teamId: string, ch
distinctUntilChanged(),
);
};
export const observeChannelsByCategoryChannelSortOrder = (database: Database, category: CategoryModel, excludeIds?: string[]) => {
return category.categoryChannelsBySortOrder.observeWithColumns(['sort_order']).pipe(
switchMap((categoryChannels) => {
const ids = categoryChannels.map((cc) => cc.channelId);
const idsStr = `'${ids.join("','")}'`;
const exclude = excludeIds?.length ? `AND c.id NOT IN ('${excludeIds.join("','")}')` : '';
return database.get<ChannelModel>(CHANNEL).query(
Q.unsafeSqlQuery(`SELECT DISTINCT c.* FROM ${CHANNEL} c INNER JOIN
${CATEGORY_CHANNEL} cc ON cc.channel_id=c.id AND c.id IN (${idsStr}) ${exclude}
ORDER BY cc.sort_order`),
).observe();
}),
);
};
export const observeChannelsByLastPostAtInCategory = (database: Database, category: CategoryModel, excludeIds?: string[]) => {
return category.myChannels.observeWithColumns(['last_post_at']).pipe(
switchMap((myChannels) => observeChannelsByLastPostAt(database, myChannels, excludeIds)),
);
};

View File

@@ -11,6 +11,7 @@ import {General, Permissions} from '@constants';
import {MM_TABLES} from '@constants/database';
import {sanitizeLikeString} from '@helpers/database';
import {hasPermission} from '@utils/role';
import {getUserIdFromChannelName} from '@utils/user';
import {prepareDeletePost} from './post';
import {queryRoles} from './role';
@@ -211,6 +212,13 @@ export const observeMyChannel = (database: Database, channelId: string) => {
);
};
export const observeMyChannelRoles = (database: Database, channelId: string) => {
return observeMyChannel(database, channelId).pipe(
switchMap((v) => of$(v?.roles)),
distinctUntilChanged(),
);
};
export const getChannelById = async (database: Database, channelId: string) => {
try {
const channel = await database.get<ChannelModel>(CHANNEL).find(channelId);
@@ -430,10 +438,6 @@ export const observeNotifyPropsByChannels = (database: Database, channels: Chann
);
};
export const queryChannelsByNames = (database: Database, names: string[]) => {
return database.get<ChannelModel>(CHANNEL).query(Q.where('name', Q.oneOf(names)));
};
export const queryMyChannelUnreads = (database: Database, currentTeamId: string) => {
return database.get<MyChannelModel>(MY_CHANNEL).query(
Q.on(
@@ -446,40 +450,42 @@ export const queryMyChannelUnreads = (database: Database, currentTeamId: string)
Q.where('delete_at', Q.eq(0)),
),
),
Q.where('is_unread', Q.eq(true)),
Q.or(
Q.where('is_unread', Q.eq(true)),
Q.where('mentions_count', Q.gte(0)),
),
Q.sortBy('last_post_at', Q.desc),
);
};
export const queryEmptyDirectAndGroupChannels = (database: Database) => {
return database.get<MyChannelModel>(MY_CHANNEL).query(
Q.on(
CHANNEL,
Q.where('team_id', Q.eq('')),
),
Q.where('last_post_at', Q.eq(0)),
);
};
export const observeArchivedDirectChannels = (database: Database, currentUserId: string) => {
const deactivatedIds = database.get<UserModel>(USER).query(
const deactivated = database.get<UserModel>(USER).query(
Q.where('delete_at', Q.gt(0)),
).observe().pipe(
switchMap((users) => of$(users.map((u) => u.id))),
);
).observe();
return deactivatedIds.pipe(
switchMap((dIds) => {
return deactivated.pipe(
switchMap((users) => {
const usersMap = new Map(users.map((u) => [u.id, u]));
return database.get<ChannelModel>(CHANNEL).query(
Q.on(
CHANNEL_MEMBERSHIP,
Q.and(
Q.where('user_id', Q.notEq(currentUserId)),
Q.where('user_id', Q.oneOf(dIds)),
Q.where('user_id', Q.oneOf(Array.from(usersMap.keys()))),
),
),
Q.where('type', 'D'),
).observe();
).observe().pipe(
switchMap((channels) => {
// eslint-disable-next-line max-nested-callbacks
return of$(new Map(channels.map((c) => {
const teammateId = getUserIdFromChannelName(currentUserId, c.name);
const user = usersMap.get(teammateId);
return [c.id, user];
})));
}),
);
}),
);
};
@@ -628,13 +634,17 @@ export const observeChannelSettings = (database: Database, channelId: string) =>
);
};
export const observeChannelsByLastPostAt = (database: Database, myChannels: MyChannelModel[], excludeIds?: string[]) => {
export const observeIsMutedSetting = (database: Database, channelId: string) => {
return observeChannelSettings(database, channelId).pipe(switchMap((s) => of$(s?.notifyProps?.mark_unread === General.MENTION)));
};
export const observeChannelsByLastPostAt = (database: Database, myChannels: MyChannelModel[]) => {
const ids = myChannels.map((c) => c.id);
const idsStr = `'${ids.join("','")}'`;
const exclude = excludeIds?.length ? `AND c.id NOT IN ('${excludeIds.join("','")}')` : '';
return database.get<ChannelModel>(CHANNEL).query(
Q.unsafeSqlQuery(`SELECT DISTINCT c.* FROM ${CHANNEL} c INNER JOIN
${MY_CHANNEL} mc ON mc.id=c.id AND c.id IN (${idsStr}) ${exclude}
${MY_CHANNEL} mc ON mc.id=c.id AND c.id IN (${idsStr})
ORDER BY CASE mc.last_post_at WHEN 0 THEN c.create_at ELSE mc.last_post_at END DESC`),
).observe();
};

View File

@@ -39,6 +39,7 @@ const {
THREAD,
THREADS_IN_TEAM,
THREAD_PARTICIPANT,
TEAM_THREADS_SYNC,
MY_CHANNEL,
} = MM_TABLES.SERVER;
@@ -99,6 +100,7 @@ export async function truncateCrtRelatedTables(serverUrl: string): Promise<{erro
[`DELETE FROM ${THREAD}`, []],
[`DELETE FROM ${THREADS_IN_TEAM}`, []],
[`DELETE FROM ${THREAD_PARTICIPANT}`, []],
[`DELETE FROM ${TEAM_THREADS_SYNC}`, []],
[`DELETE FROM ${MY_CHANNEL}`, []],
],
});

View File

@@ -18,7 +18,7 @@ const {SERVER: {POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD}} = MM_TABLES;
export const prepareDeletePost = async (post: PostModel): Promise<Model[]> => {
const preparedModels: Model[] = [post.prepareDestroyPermanently()];
const relations: Array<Query<Model>> = [post.drafts, post.postsInThread, post.files, post.reactions];
const relations: Array<Query<Model>> = [post.drafts, post.files, post.reactions];
for await (const models of relations) {
try {
models.forEach((m) => {
@@ -29,6 +29,20 @@ export const prepareDeletePost = async (post: PostModel): Promise<Model[]> => {
}
}
// If the post is a root post, delete the postsInThread model
if (!post.rootId) {
try {
const postsInThread = await post.postsInThread.fetch();
if (postsInThread) {
postsInThread.forEach((m) => {
preparedModels.push(m.prepareDestroyPermanently());
});
}
} catch {
// Record not found, do nothing
}
}
// If thread exists, delete thread, participants and threadsInTeam
try {
const thread = await post.thread.fetch();

View File

@@ -9,8 +9,8 @@ import {Database as DatabaseConstants, General, Permissions} from '@constants';
import {isDMorGM} from '@utils/channel';
import {hasPermission} from '@utils/role';
import {observeChannel, observeMyChannel} from './channel';
import {observeMyTeam} from './team';
import {observeChannel, observeMyChannelRoles} from './channel';
import {observeMyTeam, observeMyTeamRoles} from './team';
import type ChannelModel from '@typings/database/models/servers/channel';
import type PostModel from '@typings/database/models/servers/post';
@@ -41,16 +41,16 @@ export function observePermissionForChannel(database: Database, channel: Channel
if (!user || !channel) {
return of$(defaultValue);
}
const myChannel = observeMyChannel(database, channel.id);
const myTeam = channel.teamId ? observeMyTeam(database, channel.teamId) : of$(undefined);
const myChannelRoles = observeMyChannelRoles(database, channel.id);
const myTeamRoles = channel.teamId ? observeMyTeamRoles(database, channel.teamId) : of$(undefined);
return combineLatest([myChannel, myTeam]).pipe(switchMap(([mc, mt]) => {
return combineLatest([myChannelRoles, myTeamRoles]).pipe(switchMap(([mc, mt]) => {
const rolesArray = [...user.roles.split(' ')];
if (mc) {
rolesArray.push(...mc.roles.split(' '));
rolesArray.push(...mc.split(' '));
}
if (mt) {
rolesArray.push(...mt.roles.split(' '));
rolesArray.push(...mt.split(' '));
}
return queryRolesByNames(database, rolesArray).observeWithColumns(['permissions']).pipe(
switchMap((r) => of$(hasPermission(r, permission))),

View File

@@ -296,6 +296,13 @@ export const observeMyTeam = (database: Database, teamId: string) => {
);
};
export const observeMyTeamRoles = (database: Database, teamId: string) => {
return observeMyTeam(database, teamId).pipe(
switchMap((v) => of$(v?.roles)),
distinctUntilChanged(),
);
};
export const getTeamById = async (database: Database, teamId: string) => {
try {
const team = (await database.get<TeamModel>(TEAM).find(teamId));

View File

@@ -48,6 +48,13 @@ export const observeCurrentUser = (database: Database) => {
);
};
export const observeCurrentUserRoles = (database: Database) => {
return observeCurrentUser(database).pipe(
switchMap((v) => of$(v?.roles)),
distinctUntilChanged(),
);
};
export const queryAllUsers = (database: Database) => {
return database.get<UserModel>(USER).query();
};

View File

@@ -5,11 +5,11 @@ import BottomSheetM, {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheet
import React, {ReactNode, useCallback, useEffect, useMemo, useRef} from 'react';
import {DeviceEventEmitter, Handle, InteractionManager, Keyboard, StyleProp, View, ViewStyle} from 'react-native';
import useNavButtonPressed from '@app/hooks/navigation_button_pressed';
import {Events} from '@constants';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useIsTablet} from '@hooks/device';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {dismissModal} from '@screens/navigation';
import {hapticFeedback} from '@utils/general';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';

View File

@@ -6,9 +6,9 @@ import withObservables from '@nozbe/with-observables';
import {combineLatest} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeChannel, observeMyChannel} from '@queries/servers/channel';
import {observeChannel, observeMyChannelRoles} from '@queries/servers/channel';
import {queryRolesByNames} from '@queries/servers/role';
import {observeCurrentUser} from '@queries/servers/user';
import {observeCurrentUserRoles} from '@queries/servers/user';
import Intro from './intro';
@@ -16,13 +16,13 @@ import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables(['channelId'], ({channelId, database}: {channelId: string} & WithDatabaseArgs) => {
const channel = observeChannel(database, channelId);
const myChannel = observeMyChannel(database, channelId);
const me = observeCurrentUser(database);
const myChannelRoles = observeMyChannelRoles(database, channelId);
const meRoles = observeCurrentUserRoles(database);
const roles = combineLatest([me, myChannel]).pipe(
const roles = combineLatest([meRoles, myChannelRoles]).pipe(
switchMap(([user, member]) => {
const userRoles = user?.roles.split(' ');
const memberRoles = member?.roles.split(' ');
const userRoles = user?.split(' ');
const memberRoles = member?.split(' ');
const combinedRoles = [];
if (userRoles) {
combinedRoles.push(...userRoles);

View File

@@ -6,7 +6,6 @@ import {useIntl} from 'react-intl';
import {Keyboard, Platform, Text, View} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {bottomSheetSnapPoint} from '@app/utils/helpers';
import {CHANNEL_ACTIONS_OPTIONS_HEIGHT} from '@components/channel_actions/channel_actions';
import CompassIcon from '@components/compass_icon';
import CustomStatusEmoji from '@components/custom_status/custom_status_emoji';
@@ -19,6 +18,7 @@ import {useIsTablet} from '@hooks/device';
import {useDefaultHeaderHeight} from '@hooks/header';
import {bottomSheet, popTopScreen, showModal} from '@screens/navigation';
import {isTypeDMorGM} from '@utils/channel';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';

View File

@@ -10,6 +10,7 @@ import {isTypeDMorGM} from '@utils/channel';
import EditChannel from './edit_channel';
import IgnoreMentions from './ignore_mentions';
import Members from './members';
import NotificationPreference from './notification_preference';
import PinnedMessages from './pinned_messages';
type Props = {
@@ -26,7 +27,7 @@ const Options = ({channelId, type, callsEnabled}: Props) => {
{type !== General.DM_CHANNEL &&
<IgnoreMentions channelId={channelId}/>
}
{/*<NotificationPreference channelId={channelId}/>*/}
<NotificationPreference channelId={channelId}/>
<PinnedMessages channelId={channelId}/>
{type !== General.DM_CHANNEL &&
<Members channelId={channelId}/>

View File

@@ -6,7 +6,9 @@ import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeChannelSettings} from '@queries/servers/channel';
import {observeChannel, observeChannelSettings} from '@queries/servers/channel';
import {observeCurrentUser} from '@queries/servers/user';
import {getNotificationProps} from '@utils/user';
import NotificationPreference from './notification_preference';
@@ -17,13 +19,17 @@ type Props = WithDatabaseArgs & {
}
const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => {
const displayName = observeChannel(database, channelId).pipe(switchMap((c) => of$(c?.displayName)));
const settings = observeChannelSettings(database, channelId);
const userNotifyLevel = observeCurrentUser(database).pipe(switchMap((u) => of$(getNotificationProps(u).push)));
const notifyLevel = settings.pipe(
switchMap((s) => of$(s?.notifyProps.push)),
);
return {
displayName,
notifyLevel,
userNotifyLevel,
};
});

View File

@@ -7,54 +7,85 @@ import {Platform} from 'react-native';
import OptionItem from '@components/option_item';
import {NotificationLevel, Screens} from '@constants';
import {useTheme} from '@context/theme';
import {t} from '@i18n';
import {goToScreen} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity} from '@utils/theme';
import type {Options} from 'react-native-navigation';
type Props = {
channelId: string;
displayName: string;
notifyLevel: NotificationLevel;
userNotifyLevel: NotificationLevel;
}
const NotificationPreference = ({channelId, notifyLevel}: Props) => {
const notificationLevel = (notifyLevel: NotificationLevel) => {
let id = '';
let defaultMessage = '';
switch (notifyLevel) {
case NotificationLevel.ALL: {
id = t('channel_info.notification.all');
defaultMessage = 'All';
break;
}
case NotificationLevel.MENTION: {
id = t('channel_info.notification.mention');
defaultMessage = 'Mentions';
break;
}
case NotificationLevel.NONE: {
id = t('channel_info.notification.none');
defaultMessage = 'Never';
break;
}
default:
id = t('channel_info.notification.default');
defaultMessage = 'Default';
break;
}
return {id, defaultMessage};
};
const NotificationPreference = ({channelId, displayName, notifyLevel, userNotifyLevel}: Props) => {
const {formatMessage} = useIntl();
const theme = useTheme();
const title = formatMessage({id: 'channel_info.mobile_notifications', defaultMessage: 'Mobile Notifications'});
const goToMentions = preventDoubleTap(() => {
goToScreen(Screens.CHANNEL_MENTION, title, {channelId});
const goToChannelNotificationPreferences = preventDoubleTap(() => {
const options: Options = {
topBar: {
title: {
text: title,
},
subtitle: {
color: changeOpacity(theme.sidebarHeaderTextColor, 0.72),
text: displayName,
},
backButton: {
popStackOnPress: false,
},
},
};
goToScreen(Screens.CHANNEL_NOTIFICATION_PREFERENCES, title, {channelId}, options);
});
const notificationLevelToText = () => {
let id = '';
let defaultMessage = '';
switch (notifyLevel) {
case NotificationLevel.ALL: {
id = t('channel_info.notification.all');
defaultMessage = 'All';
break;
}
case NotificationLevel.MENTION: {
id = t('channel_info.notification.mention');
defaultMessage = 'Mentions';
break;
}
case NotificationLevel.NONE: {
id = t('channel_info.notification.none');
defaultMessage = 'Never';
break;
}
default:
id = t('channel_info.notification.default');
defaultMessage = 'Default';
break;
if (notifyLevel === NotificationLevel.DEFAULT) {
const userLevel = notificationLevel(userNotifyLevel);
return formatMessage(userLevel);
}
return formatMessage({id, defaultMessage});
const channelLevel = notificationLevel(notifyLevel);
return formatMessage(channelLevel);
};
return (
<OptionItem
action={goToMentions}
action={goToChannelNotificationPreferences}
label={title}
icon='cellphone'
type={Platform.select({ios: 'arrow', default: 'default'})}

View File

@@ -0,0 +1,108 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import {LayoutAnimation} from 'react-native';
import {useSharedValue} from 'react-native-reanimated';
import {updateChannelNotifyProps} from '@actions/remote/channel';
import SettingsContainer from '@components/settings/container';
import {useServerUrl} from '@context/server';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useDidUpdate from '@hooks/did_update';
import useBackNavigation from '@hooks/navigate_back';
import {popTopScreen} from '../navigation';
import MutedBanner, {MUTED_BANNER_HEIGHT} from './muted_banner';
import NotifyAbout, {BLOCK_TITLE_HEIGHT} from './notify_about';
import ResetToDefault from './reset';
import ThreadReplies from './thread_replies';
import type {AvailableScreens} from '@typings/screens/navigation';
type Props = {
channelId: string;
componentId: AvailableScreens;
defaultLevel: NotificationLevel;
defaultThreadReplies: 'all' | 'mention';
isCRTEnabled: boolean;
isMuted: boolean;
notifyLevel?: NotificationLevel;
notifyThreadReplies?: 'all' | 'mention';
}
const ChannelNotificationPreferences = ({channelId, componentId, defaultLevel, defaultThreadReplies, isCRTEnabled, isMuted, notifyLevel, notifyThreadReplies}: Props) => {
const serverUrl = useServerUrl();
const defaultNotificationReplies = defaultThreadReplies === 'all';
const diffNotificationLevel = notifyLevel !== 'default' && notifyLevel !== defaultLevel;
const notifyTitleTop = useSharedValue((isMuted ? MUTED_BANNER_HEIGHT : 0) + BLOCK_TITLE_HEIGHT);
const [notifyAbout, setNotifyAbout] = useState<NotificationLevel>((notifyLevel === undefined || notifyLevel === 'default') ? defaultLevel : notifyLevel);
const [threadReplies, setThreadReplies] = useState<boolean>((notifyThreadReplies || defaultThreadReplies) === 'all');
const [resetDefaultVisible, setResetDefaultVisible] = useState(diffNotificationLevel || defaultNotificationReplies !== threadReplies);
useDidUpdate(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
}, [isMuted]);
const onResetPressed = useCallback(() => {
setResetDefaultVisible(false);
setNotifyAbout(defaultLevel);
setThreadReplies(defaultNotificationReplies);
}, [defaultLevel, defaultNotificationReplies]);
const onNotificationLevel = useCallback((level: NotificationLevel) => {
setNotifyAbout(level);
setResetDefaultVisible(level !== defaultLevel || defaultNotificationReplies !== threadReplies);
}, [defaultLevel, defaultNotificationReplies, threadReplies]);
const onSetThreadReplies = useCallback((value: boolean) => {
setThreadReplies(value);
setResetDefaultVisible(defaultNotificationReplies !== value || notifyAbout !== defaultLevel);
}, [defaultLevel, defaultNotificationReplies, notifyAbout]);
const save = useCallback(() => {
const pushThreads = threadReplies ? 'all' : 'mention';
if (notifyLevel !== notifyAbout || (isCRTEnabled && pushThreads !== notifyThreadReplies)) {
const props: Partial<ChannelNotifyProps> = {push: notifyAbout};
if (isCRTEnabled) {
props.push_threads = pushThreads;
}
updateChannelNotifyProps(serverUrl, channelId, props);
}
popTopScreen(componentId);
}, [channelId, componentId, isCRTEnabled, notifyAbout, notifyLevel, notifyThreadReplies, serverUrl, threadReplies]);
useBackNavigation(save);
useAndroidHardwareBackHandler(componentId, save);
return (
<SettingsContainer testID='push_notification_settings'>
{isMuted && <MutedBanner channelId={channelId}/>}
{resetDefaultVisible &&
<ResetToDefault
onPress={onResetPressed}
topPosition={notifyTitleTop}
/>
}
<NotifyAbout
defaultLevel={defaultLevel}
isMuted={isMuted}
notifyLevel={notifyAbout}
notifyTitleTop={notifyTitleTop}
onPress={onNotificationLevel}
/>
{isCRTEnabled &&
<ThreadReplies
isSelected={threadReplies}
onPress={onSetThreadReplies}
notifyLevel={notifyAbout}
/>
}
</SettingsContainer>
);
};
export default ChannelNotificationPreferences;

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeChannelSettings, observeIsMutedSetting} from '@queries/servers/channel';
import {observeIsCRTEnabled} from '@queries/servers/thread';
import {observeCurrentUser} from '@queries/servers/user';
import {getNotificationProps} from '@utils/user';
import ChannelNotificationPreferences from './channel_notification_preferences';
import type {WithDatabaseArgs} from '@typings/database/database';
type EnhancedProps = WithDatabaseArgs & {
channelId: string;
}
const enhanced = withObservables([], ({channelId, database}: EnhancedProps) => {
const settings = observeChannelSettings(database, channelId);
const isCRTEnabled = observeIsCRTEnabled(database);
const isMuted = observeIsMutedSetting(database, channelId);
const notifyProps = observeCurrentUser(database).pipe(switchMap((u) => of$(getNotificationProps(u))));
const notifyLevel = settings.pipe(
switchMap((s) => of$(s?.notifyProps.push)),
);
const notifyThreadReplies = settings.pipe(
switchMap((s) => of$(s?.notifyProps.push_threads)),
);
const defaultLevel = notifyProps.pipe(
switchMap((n) => of$(n?.push)),
);
const defaultThreadReplies = notifyProps.pipe(
switchMap((n) => of$(n?.push_threads)),
);
return {
isCRTEnabled,
isMuted,
notifyLevel,
notifyThreadReplies,
defaultLevel,
defaultThreadReplies,
};
});
export default withDatabase(enhanced(ChannelNotificationPreferences));

View File

@@ -0,0 +1,102 @@
// 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 {View} from 'react-native';
import Animated, {FlipOutXUp} from 'react-native-reanimated';
import {toggleMuteChannel} from '@actions/remote/channel';
import Button from '@app/components/button';
import CompassIcon from '@app/components/compass_icon';
import FormattedText from '@app/components/formatted_text';
import {useServerUrl} from '@app/context/server';
import {useTheme} from '@app/context/theme';
import {preventDoubleTap} from '@app/utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@app/utils/theme';
import {typography} from '@utils/typography';
type Props = {
channelId: string;
}
export const MUTED_BANNER_HEIGHT = 200;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
button: {width: '55%'},
container: {
backgroundColor: changeOpacity(theme.sidebarTextActiveBorder, 0.16),
borderRadius: 4,
marginHorizontal: 20,
marginVertical: 12,
paddingHorizontal: 16,
height: MUTED_BANNER_HEIGHT,
},
contentText: {
...typography('Body', 200),
color: theme.centerChannelColor,
marginTop: 12,
marginBottom: 16,
},
titleContainer: {
alignItems: 'center',
flexDirection: 'row',
marginTop: 16,
},
title: {
...typography('Heading', 200),
color: theme.centerChannelColor,
marginLeft: 10,
paddingTop: 5,
},
}));
const MutedBanner = ({channelId}: Props) => {
const {formatMessage} = useIntl();
const serverUrl = useServerUrl();
const theme = useTheme();
const styles = getStyleSheet(theme);
const onPress = useCallback(preventDoubleTap(() => {
toggleMuteChannel(serverUrl, channelId, false);
}), [channelId, serverUrl]);
return (
<Animated.View
exiting={FlipOutXUp}
style={styles.container}
>
<View style={styles.titleContainer}>
<CompassIcon
name='bell-off-outline'
size={24}
color={theme.linkColor}
/>
<FormattedText
id='channel_notification_preferences.muted_title'
defaultMessage='This channel is muted'
style={styles.title}
/>
</View>
<FormattedText
id='channel_notification_preferences.muted_content'
defaultMessage='You can change the notification settings, but you will not receive notifications until the channel is unmuted.'
style={styles.contentText}
/>
<Button
buttonType='default'
onPress={onPress}
text={formatMessage({
id: 'channel_notification_preferences.unmute_content',
defaultMessage: 'Unmute channel',
})}
theme={theme}
backgroundStyle={styles.button}
iconName='bell-outline'
iconSize={18}
/>
</Animated.View>
);
};
export default MutedBanner;

View File

@@ -0,0 +1,93 @@
// 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 {LayoutChangeEvent, View} from 'react-native';
import SettingBlock from '@components/settings/block';
import SettingOption from '@components/settings/option';
import SettingSeparator from '@components/settings/separator';
import {NotificationLevel} from '@constants';
import {t} from '@i18n';
import type {SharedValue} from 'react-native-reanimated';
type Props = {
isMuted: boolean;
defaultLevel: NotificationLevel;
notifyLevel: NotificationLevel;
notifyTitleTop: SharedValue<number>;
onPress: (level: NotificationLevel) => void;
}
type NotifPrefOptions = {
defaultMessage: string;
id: string;
testID: string;
value: string;
}
export const BLOCK_TITLE_HEIGHT = 13;
const NOTIFY_ABOUT = {id: t('channel_notification_preferences.notify_about'), defaultMessage: 'Notify me about...'};
const NOTIFY_OPTIONS: Record<string, NotifPrefOptions> = {
[NotificationLevel.ALL]: {
defaultMessage: 'All new messages',
id: t('channel_notification_preferences.notification.all'),
testID: 'channel_notification_preferences.notification.all',
value: NotificationLevel.ALL,
},
[NotificationLevel.MENTION]: {
defaultMessage: 'Mentions, direct messages only',
id: t('channel_notification_preferences.notification.mention'),
testID: 'channel_notification_preferences.notification.mention',
value: NotificationLevel.MENTION,
},
[NotificationLevel.NONE]: {
defaultMessage: 'Nothing',
id: t('channel_notification_preferences.notification.none'),
testID: 'channel_notification_preferences.notification.none',
value: NotificationLevel.NONE,
},
};
const NotifyAbout = ({defaultLevel, isMuted, notifyLevel, notifyTitleTop, onPress}: Props) => {
const {formatMessage} = useIntl();
const onLayout = useCallback((e: LayoutChangeEvent) => {
const {y} = e.nativeEvent.layout;
notifyTitleTop.value = y > 0 ? y + 10 : BLOCK_TITLE_HEIGHT;
}, []);
return (
<SettingBlock
headerText={NOTIFY_ABOUT}
headerStyles={{marginTop: isMuted ? 8 : 12}}
onLayout={onLayout}
>
{Object.keys(NOTIFY_OPTIONS).map((key) => {
const {id, defaultMessage, value, testID} = NOTIFY_OPTIONS[key];
const defaultOption = key === defaultLevel ? formatMessage({id: 'channel_notification_preferences.default', defaultMessage: '(default)'}) : '';
const label = `${formatMessage({id, defaultMessage})} ${defaultOption}`;
return (
<View key={`notif_pref_option${key}`}>
<SettingOption
action={onPress}
label={label}
selected={notifyLevel === key}
testID={testID}
type='select'
value={value}
/>
<SettingSeparator/>
</View>
);
})}
</SettingBlock>
);
};
export default NotifyAbout;

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {TouchableOpacity} from 'react-native';
import Animated, {SharedValue, useAnimatedStyle, withTiming} from 'react-native-reanimated';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
onPress: () => void;
topPosition: SharedValue<number>;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
position: 'absolute',
right: 20,
zIndex: 1,
},
row: {flexDirection: 'row'},
text: {
color: theme.linkColor,
marginLeft: 7,
...typography('Heading', 100),
},
}));
const ResetToDefault = ({onPress, topPosition}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const animatedStyle = useAnimatedStyle(() => ({
top: withTiming(topPosition.value, {duration: 100}),
}));
return (
<Animated.View style={[styles.container, animatedStyle]}>
<TouchableOpacity
onPress={onPress}
style={styles.row}
>
<CompassIcon
name='refresh'
size={18}
color={theme.linkColor}
/>
<FormattedText
id='channel_notification_preferences.reset_default'
defaultMessage='Reset to default'
style={styles.text}
/>
</TouchableOpacity>
</Animated.View>
);
};
export default ResetToDefault;

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import SettingBlock from '@components/settings/block';
import SettingOption from '@components/settings/option';
import SettingSeparator from '@components/settings/separator';
import {NotificationLevel} from '@constants';
import {t} from '@i18n';
type Props = {
isSelected: boolean;
notifyLevel: NotificationLevel;
onPress: (selected: boolean) => void;
}
type NotifPrefOptions = {
defaultMessage: string;
id: string;
testID: string;
value: string;
}
const THREAD_REPLIES = {id: t('channel_notification_preferences.thread_replies'), defaultMessage: 'Thread replies'};
const NOTIFY_OPTIONS_THREAD: Record<string, NotifPrefOptions> = {
THREAD_REPLIES: {
defaultMessage: 'Notify me about replies to threads Im following in this channel',
id: t('channel_notification_preferences.notification.thread_replies'),
testID: 'channel_notification_preferences.notification.thread_replies',
value: 'thread_replies',
},
};
const NotifyAbout = ({isSelected, notifyLevel, onPress}: Props) => {
const {formatMessage} = useIntl();
if ([NotificationLevel.NONE, NotificationLevel.ALL].includes(notifyLevel)) {
return null;
}
return (
<SettingBlock headerText={THREAD_REPLIES}>
<SettingOption
action={onPress}
label={formatMessage({id: NOTIFY_OPTIONS_THREAD.THREAD_REPLIES.id, defaultMessage: NOTIFY_OPTIONS_THREAD.THREAD_REPLIES.defaultMessage})}
testID={NOTIFY_OPTIONS_THREAD.THREAD_REPLIES.testID}
type='toggle'
selected={isSelected}
/>
<SettingSeparator/>
</SettingBlock>
);
};
export default NotifyAbout;

View File

@@ -197,8 +197,8 @@ export default function CreateDirectMessage({
setSelectedIds((current) => removeProfileFromList(current, id));
}, []);
const createDirectChannel = useCallback(async (id: string): Promise<boolean> => {
const user = selectedIds[id];
const createDirectChannel = useCallback(async (id: string, selectedUser?: UserProfile): Promise<boolean> => {
const user = selectedUser || selectedIds[id];
const displayName = displayUsername(user, intl.locale, teammateNameDisplay);
const result = await makeDirectChannel(serverUrl, id, displayName);
@@ -219,7 +219,7 @@ export default function CreateDirectMessage({
return !result.error;
}, [serverUrl]);
const startConversation = useCallback(async (selectedId?: {[id: string]: boolean}) => {
const startConversation = useCallback(async (selectedId?: {[id: string]: boolean}, selectedUser?: UserProfile) => {
if (startingConversation) {
return;
}
@@ -233,7 +233,7 @@ export default function CreateDirectMessage({
} else if (idsToUse.length > 1) {
success = await createGroupChannel(idsToUse);
} else {
success = await createDirectChannel(idsToUse[0]);
success = await createDirectChannel(idsToUse[0], selectedUser);
}
if (success) {
@@ -249,7 +249,7 @@ export default function CreateDirectMessage({
[currentUserId]: true,
};
startConversation(selectedId);
startConversation(selectedId, user);
} else {
clearSearch();
setSelectedIds((current) => {

View File

@@ -27,6 +27,7 @@ import {General, Channel} from '@constants';
import {useTheme} from '@context/theme';
import {useAutocompleteDefaultAnimatedValues} from '@hooks/autocomplete';
import {useIsTablet, useKeyboardHeight, useModalPosition} from '@hooks/device';
import {useInputPropagation} from '@hooks/input';
import {t} from '@i18n';
import {
changeOpacity,
@@ -126,6 +127,8 @@ export default function ChannelInfoForm({
const dimensions = useWindowDimensions();
const isTablet = useIsTablet();
const [propagateValue, shouldProcessEvent] = useInputPropagation();
const keyboardHeight = useKeyboardHeight();
const [keyboardVisible, setKeyBoardVisible] = useState(false);
const [scrollPosition, setScrollPosition] = useState(0);
@@ -193,6 +196,18 @@ export default function ChannelInfoForm({
}
}, [keyboardHeight]);
const onHeaderAutocompleteChange = useCallback((value: string) => {
onHeaderChange(value);
propagateValue(value);
}, [onHeaderChange]);
const onHeaderInputChange = useCallback((value: string) => {
if (!shouldProcessEvent(value)) {
return;
}
onHeaderChange(value);
}, [onHeaderChange]);
const onLayoutError = useCallback((e: LayoutChangeEvent) => {
setErrorHeight(e.nativeEvent.layout.height);
}, []);
@@ -371,7 +386,7 @@ export default function ChannelInfoForm({
enablesReturnKeyAutomatically={true}
label={labelHeader}
placeholder={placeholderHeader}
onChangeText={onHeaderChange}
onChangeText={onHeaderInputChange}
multiline={true}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
returnKeyType='next'
@@ -397,7 +412,7 @@ export default function ChannelInfoForm({
</KeyboardAwareScrollView>
<Autocomplete
position={animatedAutocompletePosition}
updateValue={onHeaderChange}
updateValue={onHeaderAutocompleteChange}
cursorPosition={header.length}
value={header}
nestedScrollEnabled={true}

View File

@@ -15,6 +15,7 @@ import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useAutocompleteDefaultAnimatedValues} from '@hooks/autocomplete';
import {useIsTablet, useKeyboardHeight, useModalPosition} from '@hooks/device';
import useDidUpdate from '@hooks/did_update';
import {useInputPropagation} from '@hooks/input';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import PostError from '@screens/edit_post/post_error';
import {buildNavigationButton, dismissModal, setButtons} from '@screens/navigation';
@@ -61,6 +62,7 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach
const [errorExtra, setErrorExtra] = useState<string | undefined>();
const [isUpdating, setIsUpdating] = useState(false);
const [containerHeight, setContainerHeight] = useState(0);
const [propagateValue, shouldProcessEvent] = useInputPropagation();
const mainView = useRef<View>(null);
const modalPosition = useModalPosition(mainView);
@@ -123,10 +125,8 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach
});
}, [componentId, intl, theme]);
const onChangeText = useCallback((message: string) => {
setPostMessage(message);
const onChangeTextCommon = useCallback((message: string) => {
const tooLong = message.trim().length > maxPostSize;
if (tooLong) {
const line = intl.formatMessage({id: 'mobile.message_length.message_split_left', defaultMessage: 'Message exceeds the character limit'});
const extra = `${message.trim().length} / ${maxPostSize}`;
@@ -134,7 +134,21 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach
setErrorExtra(extra);
}
toggleSaveButton(post.message !== message);
}, [intl, maxPostSize, toggleSaveButton]);
}, [intl, maxPostSize, post.message, toggleSaveButton]);
const onAutocompleteChangeText = useCallback((message: string) => {
setPostMessage(message);
propagateValue(message);
onChangeTextCommon(message);
}, [onChangeTextCommon]);
const onInputChangeText = useCallback((message: string) => {
if (!shouldProcessEvent(message)) {
return;
}
setPostMessage(message);
onChangeTextCommon(message);
}, [onChangeTextCommon]);
const handleUIUpdates = useCallback((res: {error?: unknown}) => {
if (res.error) {
@@ -233,7 +247,7 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach
<EditPostInput
hasError={Boolean(errorLine)}
message={postMessage}
onChangeText={onChangeText}
onChangeText={onInputChangeText}
onTextSelectionChange={onTextSelectionChange}
ref={postInputRef}
/>
@@ -244,7 +258,7 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach
hasFilesAttached={hasFilesAttached}
nestedScrollEnabled={true}
rootId={post.rootId}
updateValue={onChangeText}
updateValue={onAutocompleteChangeText}
value={postMessage}
cursorPosition={cursorPosition}
position={animatedAutocompletePosition}

View File

@@ -102,6 +102,7 @@ const EditProfile = ({
popTopScreen(componentId);
}
}, []);
const enableSaveButton = useCallback((value: boolean) => {
if (!isTablet) {
const buttons = {
@@ -114,18 +115,19 @@ const EditProfile = ({
}
setCanSave(value);
}, [componentId, rightButton]);
const submitUser = useCallback(preventDoubleTap(async () => {
enableSaveButton(false);
setError(undefined);
setUpdating(true);
try {
const newUserInfo: Partial<UserProfile> = {
email: userInfo.email,
first_name: userInfo.firstName,
last_name: userInfo.lastName,
nickname: userInfo.nickname,
position: userInfo.position,
username: userInfo.username,
email: userInfo.email.trim(),
first_name: userInfo.firstName.trim(),
last_name: userInfo.lastName.trim(),
nickname: userInfo.nickname.trim(),
position: userInfo.position.trim(),
username: userInfo.username.trim(),
};
const localPath = changedProfilePicture.current?.localPath;
const profileImageRemoved = changedProfilePicture.current?.isRemoved;

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {Platform, StyleSheet} from 'react-native';
import {InteractionManager, Platform, StyleSheet} from 'react-native';
import Animated, {
EntryAnimationsValues, ExitAnimationsValues, FadeIn, FadeOut,
SharedValue, useAnimatedStyle, withDelay, withTiming,
@@ -125,13 +125,11 @@ const SkinToneSelector = ({skinTone = 'default', containerWidth, isSearching, tu
}, []);
useEffect(() => {
const t = setTimeout(() => {
InteractionManager.runAfterInteractions(() => {
if (!tutorialWatched) {
setTooltipVisible(true);
}
}, 750);
return () => clearTimeout(t);
});
}, []);
return (

View File

@@ -12,29 +12,9 @@ import TestHelper from '@test/test_helper';
import CategoryBody from '.';
import type CategoryModel from '@typings/database/models/servers/category';
import type CategoryChannelModel from '@typings/database/models/servers/category_channel';
import type ChannelModel from '@typings/database/models/servers/channel';
const {SERVER: {CATEGORY}} = MM_TABLES;
jest.mock('@queries/servers/categories', () => {
const Queries = jest.requireActual('@queries/servers/categories');
const switchMap = jest.requireActual('rxjs/operators').switchMap;
const mQ = jest.requireActual('@nozbe/watermelondb').Q;
return {
...Queries,
observeChannelsByCategoryChannelSortOrder: (database: Database, category: CategoryModel, excludeIds?: string[]) => {
return category.categoryChannelsBySortOrder.observeWithColumns(['sort_order']).pipe(
switchMap((categoryChannels: CategoryChannelModel[]) => {
const ids = categoryChannels.filter((cc) => excludeIds?.includes(cc.channelId)).map((cc) => cc.channelId);
return database.get<ChannelModel>('Channel').query(mQ.where('id', mQ.oneOf(ids))).observe();
}),
);
},
};
});
describe('components/channel_list/categories/body', () => {
let database: Database;
let category: CategoryModel;

View File

@@ -7,7 +7,6 @@ import Animated, {Easing, useAnimatedStyle, useSharedValue, withTiming} from 're
import {fetchDirectChannelsInfo} from '@actions/remote/channel';
import ChannelItem from '@components/channel_item';
import {DMS_CATEGORY} from '@constants/categories';
import {useServerUrl} from '@context/server';
import {isDMorGM} from '@utils/channel';
@@ -17,7 +16,6 @@ import type ChannelModel from '@typings/database/models/servers/channel';
type Props = {
sortedChannels: ChannelModel[];
category: CategoryModel;
limit: number;
onChannelSwitch: (channelId: string) => void;
unreadIds: Set<string>;
unreadsOnTop: boolean;
@@ -25,16 +23,13 @@ type Props = {
const extractKey = (item: ChannelModel) => item.id;
const CategoryBody = ({sortedChannels, unreadIds, unreadsOnTop, category, limit, onChannelSwitch}: Props) => {
const CategoryBody = ({sortedChannels, unreadIds, unreadsOnTop, category, onChannelSwitch}: Props) => {
const serverUrl = useServerUrl();
const ids = useMemo(() => {
const filteredChannels = unreadsOnTop ? sortedChannels.filter((c) => !unreadIds.has(c.id)) : sortedChannels;
if (category.type === DMS_CATEGORY && limit > 0) {
return filteredChannels.slice(0, limit);
}
return filteredChannels;
}, [category.type, limit, sortedChannels, unreadIds, unreadsOnTop]);
}, [category.type, sortedChannels, unreadIds, unreadsOnTop]);
const unreadChannels = useMemo(() => {
return unreadsOnTop ? [] : ids.filter((c) => unreadIds.has(c.id));

View File

@@ -1,20 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Database, Q} from '@nozbe/watermelondb';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {combineLatest, of as of$} from 'rxjs';
import {map, switchMap, combineLatestWith} from 'rxjs/operators';
import {of as of$, Observable} from 'rxjs';
import {switchMap, combineLatestWith, distinctUntilChanged} from 'rxjs/operators';
import {General, Preferences} from '@constants';
import {Preferences} from '@constants';
import {DMS_CATEGORY} from '@constants/categories';
import {getSidebarPreferenceAsBool} from '@helpers/api/preference';
import {observeChannelsByCategoryChannelSortOrder, observeChannelsByLastPostAtInCategory} from '@queries/servers/categories';
import {observeArchivedDirectChannels, observeNotifyPropsByChannels, queryChannelsByNames, queryEmptyDirectAndGroupChannels} from '@queries/servers/channel';
import {observeArchivedDirectChannels, observeNotifyPropsByChannels} from '@queries/servers/channel';
import {queryPreferencesByCategoryAndName, querySidebarPreferences} from '@queries/servers/preference';
import {observeCurrentChannelId, observeCurrentUserId, observeLastUnreadChannelId} from '@queries/servers/system';
import {getDirectChannelName} from '@utils/channel';
import {ChannelWithMyChannel, filterArchivedChannels, filterAutoclosedDMs, filterManuallyClosedDms, getUnreadIds, sortChannels} from '@utils/categories';
import CategoryBody from './category_body';
@@ -24,10 +22,6 @@ import type ChannelModel from '@typings/database/models/servers/channel';
import type MyChannelModel from '@typings/database/models/servers/my_channel';
import type PreferenceModel from '@typings/database/models/servers/preference';
type ChannelData = Pick<ChannelModel, 'id' | 'displayName'> & {
isMuted: boolean;
};
type EnhanceProps = {
category: CategoryModel;
locale: string;
@@ -35,87 +29,45 @@ type EnhanceProps = {
isTablet: boolean;
} & WithDatabaseArgs
const sortAlpha = (locale: string, a: ChannelData, b: ChannelData) => {
if (a.isMuted && !b.isMuted) {
return 1;
} else if (!a.isMuted && b.isMuted) {
return -1;
}
return a.displayName.localeCompare(b.displayName, locale, {numeric: true});
};
const filterArchived = (channels: Array<ChannelModel | null>, currentChannelId: string) => {
return channels.filter((c): c is ChannelModel => c != null && ((c.deleteAt > 0 && c.id === currentChannelId) || !c.deleteAt));
};
const buildAlphaData = (channels: ChannelModel[], notifyProps: Record<string, Partial<ChannelNotifyProps>>, locale: string) => {
const chanelsById = channels.reduce((result: Record<string, ChannelModel>, c) => {
result[c.id] = c;
return result;
}, {});
const combined = channels.map((c) => {
const s = notifyProps[c.id];
return {
id: c.id,
displayName: c.displayName,
isMuted: s?.mark_unread === General.MENTION,
};
});
combined.sort(sortAlpha.bind(null, locale));
return of$(combined.map((cdata) => chanelsById[cdata.id]));
};
const observeSortedChannels = (database: Database, category: CategoryModel, excludeIds: string[], locale: string) => {
switch (category.sorting) {
case 'alpha': {
const channels = category.channels.extend(Q.where('id', Q.notIn(excludeIds))).observeWithColumns(['display_name']);
const notifyProps = channels.pipe(switchMap((cs) => observeNotifyPropsByChannels(database, cs)));
return combineLatest([channels, notifyProps]).pipe(
switchMap(([cs, np]) => buildAlphaData(cs, np, locale)),
);
}
case 'manual': {
return observeChannelsByCategoryChannelSortOrder(database, category, excludeIds);
}
default:
return observeChannelsByLastPostAtInCategory(database, category, excludeIds);
}
};
const mapPrefName = (prefs: PreferenceModel[]) => of$(prefs.map((p) => p.name));
const mapChannelIds = (channels: ChannelModel[] | MyChannelModel[]) => of$(channels.map((c) => c.id));
const withUserId = withObservables([], ({database}: WithDatabaseArgs) => ({currentUserId: observeCurrentUserId(database)}));
const enhance = withObservables(['category', 'isTablet', 'locale'], ({category, locale, isTablet, database, currentUserId}: EnhanceProps) => {
const dmMap = (p: PreferenceModel) => getDirectChannelName(p.name, currentUserId);
const observeCategoryChannels = (category: CategoryModel, myChannels: Observable<MyChannelModel[]>) => {
const channels = category.channels.observeWithColumns(['create_at', 'display_name']);
const manualSort = category.categoryChannelsBySortOrder.observeWithColumns(['sort_order']);
return myChannels.pipe(
combineLatestWith(channels, manualSort),
switchMap(([my, cs, sorted]) => {
const channelMap = new Map<string, ChannelModel>(cs.map((c) => [c.id, c]));
const categoryChannelMap = new Map<string, number>(sorted.map((s) => [s.channelId, s.sortOrder]));
return of$(my.reduce<ChannelWithMyChannel[]>((result, myChannel) => {
const channel = channelMap.get(myChannel.id);
if (channel) {
const channelWithMyChannel: ChannelWithMyChannel = {
channel,
myChannel,
sortOrder: categoryChannelMap.get(myChannel.id) || 0,
};
result.push(channelWithMyChannel);
}
const currentChannelId = observeCurrentChannelId(database);
return result;
}, []));
}),
);
};
const hiddenDmIds = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.DIRECT_CHANNEL_SHOW, undefined, 'false').
observeWithColumns(['value']).pipe(
switchMap((prefs: PreferenceModel[]) => {
const names = prefs.map(dmMap);
const channels = queryChannelsByNames(database, names).observe();
const enhanced = withObservables([], ({category, currentUserId, database, isTablet, locale}: EnhanceProps) => {
const categoryMyChannels = category.myChannels.observeWithColumns(['last_post_at', 'is_unread']);
const channelsWithMyChannel = observeCategoryChannels(category, categoryMyChannels);
const currentChannelId = isTablet ? observeCurrentChannelId(database) : of$('');
const lastUnreadId = isTablet ? observeLastUnreadChannelId(database) : of$(undefined);
return channels.pipe(
switchMap(mapChannelIds),
);
}),
const unreadsOnTop = querySidebarPreferences(database, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS).
observeWithColumns(['value']).
pipe(
switchMap((prefs: PreferenceModel[]) => of$(getSidebarPreferenceAsBool(prefs, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS))),
);
const emptyDmIds = queryEmptyDirectAndGroupChannels(database).observeWithColumns(['last_post_at']).pipe(
switchMap(mapChannelIds),
);
const archivedDmIds = observeArchivedDirectChannels(database, currentUserId).pipe(
switchMap(mapChannelIds),
);
let limit = of$(Preferences.CHANNEL_SIDEBAR_LIMIT_DMS_DEFAULT);
if (category.type === DMS_CATEGORY) {
limit = querySidebarPreferences(database, Preferences.CHANNEL_SIDEBAR_LIMIT_DMS).
@@ -126,54 +78,61 @@ const enhance = withObservables(['category', 'isTablet', 'locale'], ({category,
);
}
const unreadsOnTop = querySidebarPreferences(database, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS).
observeWithColumns(['value']).
pipe(
switchMap((prefs: PreferenceModel[]) => of$(getSidebarPreferenceAsBool(prefs, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS))),
);
const lastUnreadId = isTablet ? observeLastUnreadChannelId(database) : of$(undefined);
const hiddenChannelIds = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.GROUP_CHANNEL_SHOW, undefined, 'false').
observeWithColumns(['value']).pipe(
switchMap(mapPrefName),
combineLatestWith(hiddenDmIds, emptyDmIds, archivedDmIds, lastUnreadId),
switchMap(([hIds, hDmIds, eDmIds, aDmIds, excludeId]) => {
const hidden = new Set(hIds.concat(hDmIds, eDmIds, aDmIds));
if (excludeId) {
hidden.delete(excludeId);
}
return of$(hidden);
}),
);
const sortedChannels = hiddenChannelIds.pipe(
switchMap((excludeIds) => observeSortedChannels(database, category, Array.from(excludeIds), locale)),
combineLatestWith(currentChannelId),
map(([channels, ccId]) => filterArchived(channels, ccId)),
const notifyPropsPerChannel = categoryMyChannels.pipe(
// eslint-disable-next-line max-nested-callbacks
switchMap((mc) => observeNotifyPropsByChannels(database, mc)),
);
const unreadChannels = category.myChannels.observeWithColumns(['mentions_count', 'is_unread']);
const notifyProps = unreadChannels.pipe(switchMap((myChannels) => observeNotifyPropsByChannels(database, myChannels)));
const unreadIds = unreadChannels.pipe(
combineLatestWith(notifyProps, lastUnreadId),
map(([my, settings, lastUnread]) => {
return my.reduce<Set<string>>((set, m) => {
const isMuted = settings[m.id]?.mark_unread === 'mention';
if ((isMuted && m.mentionsCount) || (!isMuted && m.isUnread) || m.id === lastUnread) {
set.add(m.id);
}
return set;
}, new Set());
const hiddenDmPrefs = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.DIRECT_CHANNEL_SHOW, undefined, 'false').
observeWithColumns(['value']);
const hiddenGmPrefs = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.GROUP_CHANNEL_SHOW, undefined, 'false').
observeWithColumns(['value']);
const manuallyClosedPrefs = hiddenDmPrefs.pipe(
combineLatestWith(hiddenGmPrefs),
switchMap(([dms, gms]) => of$(dms.concat(gms))),
);
const approxViewTimePrefs = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.CHANNEL_APPROXIMATE_VIEW_TIME, undefined).
observeWithColumns(['value']);
const openTimePrefs = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.CHANNEL_OPEN_TIME, undefined).
observeWithColumns(['value']);
const autoclosePrefs = approxViewTimePrefs.pipe(
combineLatestWith(openTimePrefs),
switchMap(([viewTimes, openTimes]) => of$(viewTimes.concat(openTimes))),
);
const categorySorting = category.observe().pipe(
switchMap((c) => of$(c.sorting)),
distinctUntilChanged(),
);
const deactivated = (category.type === DMS_CATEGORY) ? observeArchivedDirectChannels(database, currentUserId) : of$(undefined);
const sortedChannels = channelsWithMyChannel.pipe(
combineLatestWith(categorySorting, currentChannelId, lastUnreadId, notifyPropsPerChannel, manuallyClosedPrefs, autoclosePrefs, deactivated, limit),
switchMap(([cwms, sorting, channelId, unreadId, notifyProps, manuallyClosedDms, autoclose, deactivatedDMS, maxDms]) => {
let channelsW = cwms;
channelsW = filterArchivedChannels(channelsW, channelId);
channelsW = filterManuallyClosedDms(channelsW, notifyProps, manuallyClosedDms, currentUserId, unreadId);
channelsW = filterAutoclosedDMs(category.type, maxDms, channelId, channelsW, autoclose, notifyProps, deactivatedDMS, unreadId);
return of$(sortChannels(sorting, channelsW, notifyProps, locale));
}),
);
const unreadIds = channelsWithMyChannel.pipe(
combineLatestWith(notifyPropsPerChannel, lastUnreadId),
switchMap(([cwms, notifyProps, unreadId]) => {
return of$(getUnreadIds(cwms, notifyProps, unreadId));
}),
);
return {
limit,
sortedChannels,
unreadsOnTop,
unreadIds,
category,
sortedChannels,
unreadIds,
unreadsOnTop,
};
});
export default withDatabase(withUserId(enhance(CategoryBody)));
export default withDatabase(withUserId(enhanced(CategoryBody)));

View File

@@ -54,7 +54,7 @@ const enhanced = withObservables(['currentTeamId', 'isTablet', 'onlyUnreads'], (
const channels = myUnreadChannels.pipe(switchMap((myChannels) => observeChannelsByLastPostAt(database, myChannels)));
const channelsMap = channels.pipe(switchMap((cs) => of$(makeChannelsMap(cs))));
return queryMyChannelUnreads(database, currentTeamId).observeWithColumns(['last_post_at', 'is_unread']).pipe(
return myUnreadChannels.pipe(
combineLatestWith(channelsMap, notifyProps),
map(filterAndSortMyChannels),
combineLatestWith(lastUnread),

View File

@@ -4,7 +4,7 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {combineLatest, of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import {Permissions} from '@constants';
import {observePermissionForTeam} from '@queries/servers/role';
@@ -25,6 +25,7 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const canJoinChannels = combineLatest([currentUser, team]).pipe(
switchMap(([u, t]) => observePermissionForTeam(database, t, u, Permissions.JOIN_PUBLIC_CHANNELS, true)),
distinctUntilChanged(),
);
const canCreatePublicChannels = combineLatest([currentUser, team]).pipe(
@@ -37,6 +38,7 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const canCreateChannels = combineLatest([canCreatePublicChannels, canCreatePrivateChannels]).pipe(
switchMap(([open, priv]) => of$(open || priv)),
distinctUntilChanged(),
);
const canAddUserToTeam = combineLatest([currentUser, team]).pipe(
@@ -48,9 +50,11 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
canJoinChannels,
canInvitePeople: combineLatest([enableOpenServer, canAddUserToTeam]).pipe(
switchMap(([openServer, addUser]) => of$(openServer && addUser)),
distinctUntilChanged(),
),
displayName: team.pipe(
switchMap((t) => of$(t?.displayName)),
distinctUntilChanged(),
),
pushProxyStatus: observePushVerificationStatus(database),
};

View File

@@ -33,8 +33,8 @@ describe('components/categories_list', () => {
it('should render', () => {
const wrapper = renderWithEverything(
<CategoriesList
teamsCount={1}
channelsCount={1}
moreThanOneTeam={false}
hasChannels={true}
/>,
{database},
);
@@ -46,8 +46,8 @@ describe('components/categories_list', () => {
const wrapper = renderWithEverything(
<CategoriesList
isCRTEnabled={true}
teamsCount={1}
channelsCount={1}
moreThanOneTeam={false}
hasChannels={true}
/>,
{database},
);
@@ -67,8 +67,8 @@ describe('components/categories_list', () => {
jest.useFakeTimers();
const wrapper = renderWithEverything(
<CategoriesList
teamsCount={0}
channelsCount={1}
moreThanOneTeam={false}
hasChannels={true}
/>,
{database},
);
@@ -89,8 +89,8 @@ describe('components/categories_list', () => {
jest.useFakeTimers();
const wrapper = renderWithEverything(
<CategoriesList
teamsCount={1}
channelsCount={0}
moreThanOneTeam={true}
hasChannels={false}
/>,
{database},
);

View File

@@ -27,28 +27,28 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
}));
type ChannelListProps = {
channelsCount: number;
hasChannels: boolean;
iconPad?: boolean;
isCRTEnabled?: boolean;
teamsCount: number;
moreThanOneTeam: boolean;
};
const getTabletWidth = (teamsCount: number) => {
return TABLET_SIDEBAR_WIDTH - (teamsCount > 1 ? TEAM_SIDEBAR_WIDTH : 0);
const getTabletWidth = (moreThanOneTeam: boolean) => {
return TABLET_SIDEBAR_WIDTH - (moreThanOneTeam ? TEAM_SIDEBAR_WIDTH : 0);
};
const CategoriesList = ({channelsCount, iconPad, isCRTEnabled, teamsCount}: ChannelListProps) => {
const CategoriesList = ({hasChannels, iconPad, isCRTEnabled, moreThanOneTeam}: ChannelListProps) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const {width} = useWindowDimensions();
const isTablet = useIsTablet();
const tabletWidth = useSharedValue(isTablet ? getTabletWidth(teamsCount) : 0);
const tabletWidth = useSharedValue(isTablet ? getTabletWidth(moreThanOneTeam) : 0);
useEffect(() => {
if (isTablet) {
tabletWidth.value = getTabletWidth(teamsCount);
tabletWidth.value = getTabletWidth(moreThanOneTeam);
}
}, [isTablet && teamsCount]);
}, [isTablet && moreThanOneTeam]);
const tabletStyle = useAnimatedStyle(() => {
if (!isTablet) {
@@ -61,7 +61,7 @@ const CategoriesList = ({channelsCount, iconPad, isCRTEnabled, teamsCount}: Chan
}, [isTablet, width]);
const content = useMemo(() => {
if (channelsCount < 1) {
if (!hasChannels) {
return (<LoadChannelsError/>);
}

View File

@@ -29,10 +29,10 @@ import Servers from './servers';
import type {LaunchType} from '@typings/launch';
type ChannelProps = {
channelsCount: number;
hasChannels: boolean;
isCRTEnabled: boolean;
teamsCount: number;
time?: number;
hasTeams: boolean;
hasMoreThanOneTeam: boolean;
isLicensed: boolean;
showToS: boolean;
launchType: LaunchType;
@@ -127,10 +127,10 @@ const ChannelListScreen = (props: ChannelProps) => {
}, [theme, insets.top]);
useEffect(() => {
if (!props.teamsCount) {
if (!props.hasTeams) {
resetToTeams();
}
}, [Boolean(props.teamsCount)]);
}, [Boolean(props.hasTeams)]);
useEffect(() => {
const back = BackHandler.addEventListener('hardwareBackPress', handleBackPress);
@@ -177,13 +177,13 @@ const ChannelListScreen = (props: ChannelProps) => {
>
<TeamSidebar
iconPad={canAddOtherServers}
teamsCount={props.teamsCount}
hasMoreThanOneTeam={props.hasMoreThanOneTeam}
/>
<CategoriesList
iconPad={canAddOtherServers && props.teamsCount <= 1}
iconPad={canAddOtherServers && !props.hasMoreThanOneTeam}
isCRTEnabled={props.isCRTEnabled}
teamsCount={props.teamsCount}
channelsCount={props.channelsCount}
moreThanOneTeam={props.hasMoreThanOneTeam}
hasChannels={props.hasChannels}
/>
{isTablet &&
<AdditionalTabletView/>

View File

@@ -4,7 +4,7 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import {queryAllMyChannelsForTeam} from '@queries/servers/channel';
import {observeCurrentTeamId, observeLicense} from '@queries/servers/system';
@@ -21,11 +21,22 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
switchMap((lcs) => (lcs ? of$(lcs.IsLicensed === 'true') : of$(false))),
);
const teamsCount = queryMyTeams(database).observeCount(false);
return {
isCRTEnabled: observeIsCRTEnabled(database),
teamsCount: queryMyTeams(database).observeCount(false),
channelsCount: observeCurrentTeamId(database).pipe(
hasTeams: teamsCount.pipe(
switchMap((v) => of$(v > 0)),
distinctUntilChanged(),
),
hasMoreThanOneTeam: teamsCount.pipe(
switchMap((v) => of$(v > 1)),
distinctUntilChanged(),
),
hasChannels: observeCurrentTeamId(database).pipe(
switchMap((id) => (id ? queryAllMyChannelsForTeam(database, id).observeCount(false) : of$(0))),
switchMap((v) => of$(v > 0)),
distinctUntilChanged(),
),
isLicensed,
showToS: observeShowToS(database),

View File

@@ -9,7 +9,6 @@ import Swipeable from 'react-native-gesture-handler/Swipeable';
import {Navigation} from 'react-native-navigation';
import {storeMultiServerTutorial} from '@actions/app/global';
import {appEntry} from '@actions/remote/entry';
import {doPing} from '@actions/remote/general';
import {logout} from '@actions/remote/session';
import {fetchConfigAndLicense} from '@actions/remote/systems';
@@ -24,6 +23,7 @@ import {useTheme} from '@context/theme';
import DatabaseManager from '@database/manager';
import {subscribeServerUnreadAndMentions, UnreadObserverArgs} from '@database/subscription/unreads';
import {useIsTablet} from '@hooks/device';
import WebsocketManager from '@managers/websocket_manager';
import {getServerByIdentifier} from '@queries/app/servers';
import {dismissBottomSheet} from '@screens/navigation';
import {canReceiveNotifications} from '@utils/push_proxy';
@@ -296,7 +296,7 @@ const ServerItem = ({
await dismissBottomSheet();
Navigation.updateProps(Screens.HOME, {extra: undefined});
DatabaseManager.setActiveServerDatabase(server.url);
await appEntry(server.url, Date.now());
WebsocketManager.initializeClient(server.url);
return;
}

View File

@@ -36,7 +36,6 @@ enableFreeze(true);
type HomeProps = LaunchProps & {
componentId: string;
time?: number;
};
const Tab = createBottomTabNavigator();
@@ -46,7 +45,7 @@ export default function HomeScreen(props: HomeProps) {
const intl = useIntl();
useEffect(() => {
const listener = DeviceEventEmitter.addListener(Events.NOTIFICATION_ERROR, (value: 'Team' | 'Channel') => {
const listener = DeviceEventEmitter.addListener(Events.NOTIFICATION_ERROR, (value: 'Team' | 'Channel' | 'Post' | 'Connection') => {
notificationError(intl, value);
});

View File

@@ -5,6 +5,7 @@ import {useIsFocused, useNavigation} from '@react-navigation/native';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {FlatList, LayoutChangeEvent, Platform, StyleSheet, ViewStyle} from 'react-native';
import HWKeyboardEvent from 'react-native-hw-keyboard-event';
import Animated, {useAnimatedStyle, useDerivedValue, withTiming} from 'react-native-reanimated';
import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
@@ -16,12 +17,14 @@ import FreezeScreen from '@components/freeze_screen';
import Loading from '@components/loading';
import NavigationHeader from '@components/navigation_header';
import RoundedHeaderContext from '@components/rounded_header_context';
import {Screens} from '@constants';
import {BOTTOM_TAB_HEIGHT} from '@constants/view';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useKeyboardHeight} from '@hooks/device';
import useDidUpdate from '@hooks/did_update';
import {useCollapsibleHeader} from '@hooks/header';
import NavigationStore from '@store/navigation_store';
import {FileFilter, FileFilters, filterFileExtensions} from '@utils/file';
import {TabTypes, TabType} from '@utils/search';
@@ -317,6 +320,19 @@ const SearchScreen = ({teamId, teams}: Props) => {
}
}, [isFocused]);
useEffect(() => {
const listener = HWKeyboardEvent.onHWKeyPressed((keyEvent: {pressedKey: string}) => {
const topScreen = NavigationStore.getVisibleScreen();
if (topScreen === Screens.HOME && isFocused && keyEvent.pressedKey === 'enter') {
searchRef.current?.blur();
onSubmit();
}
});
return () => {
listener.remove();
};
}, [onSubmit]);
return (
<FreezeScreen freeze={!isFocused}>
<NavigationHeader

View File

@@ -79,6 +79,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.CHANNEL:
screen = withServerDatabase(require('@screens/channel').default);
break;
case Screens.CHANNEL_NOTIFICATION_PREFERENCES:
screen = withServerDatabase(require('@screens/channel_notification_preferences').default);
break;
case Screens.CHANNEL_INFO:
screen = withServerDatabase(require('@screens/channel_info').default);
break;

View File

@@ -8,14 +8,14 @@ import {Keyboard, TextInput, TouchableOpacity, View} from 'react-native';
import Button from 'react-native-button';
import {login} from '@actions/remote/session';
import CompassIcon from '@app/components/compass_icon';
import ClientError from '@client/rest/error';
import CompassIcon from '@components/compass_icon';
import FloatingTextInput from '@components/floating_text_input_label';
import FormattedText from '@components/formatted_text';
import Loading from '@components/loading';
import {FORGOT_PASSWORD, MFA} from '@constants/screens';
import {t} from '@i18n';
import {goToScreen, loginAnimationOptions, resetToHome, resetToTeams} from '@screens/navigation';
import {goToScreen, loginAnimationOptions, resetToHome} from '@screens/navigation';
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
import {isServerError} from '@utils/errors';
import {preventDoubleTap} from '@utils/tap';
@@ -99,17 +99,13 @@ const LoginForm = ({config, extra, serverDisplayName, launchError, launchType, l
const signIn = async () => {
const result: LoginActionResponse = await login(serverUrl!, {serverDisplayName, loginId: loginId.toLowerCase(), password, config, license});
if (checkLoginResponse(result)) {
if (!result.hasTeams && !result.error) {
resetToTeams();
return;
}
goToHome(result.time || 0, result.error as never);
goToHome(result.error as never);
}
};
const goToHome = (time: number, loginError?: never) => {
const goToHome = (loginError?: never) => {
const hasError = launchError || Boolean(loginError);
resetToHome({extra, launchError: hasError, launchType, serverUrl, time});
resetToHome({extra, launchError: hasError, launchType, serverUrl});
};
const checkLoginResponse = (data: LoginActionResponse) => {

View File

@@ -19,7 +19,7 @@ import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useIsTablet} from '@hooks/device';
import {t} from '@i18n';
import Background from '@screens/background';
import {popTopScreen, resetToTeams} from '@screens/navigation';
import {popTopScreen} from '@screens/navigation';
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@@ -32,7 +32,7 @@ import type {AvailableScreens} from '@typings/screens/navigation';
type MFAProps = {
componentId: AvailableScreens;
config: Partial<ClientConfig>;
goToHome: (time: number, error?: never) => void;
goToHome: (error?: never) => void;
license: Partial<ClientLicense>;
loginId: string;
password: string;
@@ -156,11 +156,7 @@ const MFA = ({componentId, config, goToHome, license, loginId, password, serverD
setError(result.error.message);
return;
}
if (!result.hasTeams && !result.error) {
resetToTeams();
return;
}
goToHome(result.time || 0, result.error as never);
goToHome(result.error as never);
}), [token]);
const transform = useAnimatedStyle(() => {

View File

@@ -31,12 +31,13 @@ import type ChannelModel from '@typings/database/models/servers/channel';
import type PostModel from '@typings/database/models/servers/post';
import type ReactionModel from '@typings/database/models/servers/reaction';
import type UserModel from '@typings/database/models/servers/user';
import type {AvailableScreens} from '@typings/screens/navigation';
type EnhancedProps = WithDatabaseArgs & {
combinedPost?: Post | PostModel;
post: PostModel;
showAddReaction: boolean;
location: string;
sourceScreen: AvailableScreens;
serverUrl: string;
}
@@ -75,7 +76,7 @@ const withPost = withObservables([], ({post, database}: {post: Post | PostModel}
};
});
const enhanced = withObservables([], ({combinedPost, post, showAddReaction, location, database, serverUrl}: EnhancedProps) => {
const enhanced = withObservables([], ({combinedPost, post, showAddReaction, sourceScreen, database, serverUrl}: EnhancedProps) => {
const channel = observeChannel(database, post.channelId);
const channelIsArchived = channel.pipe(switchMap((ch: ChannelModel) => of$(ch.deleteAt !== 0)));
const currentUser = observeCurrentUser(database);
@@ -112,7 +113,7 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, loca
);
const canReply = combineLatest([channelIsArchived, channelIsReadOnly, canPostPermission]).pipe(switchMap(([isArchived, isReadOnly, canPost]) => {
return of$(!isArchived && !isReadOnly && location !== Screens.THREAD && !isSystemMessage(post) && canPost);
return of$(!isArchived && !isReadOnly && sourceScreen !== Screens.THREAD && !isSystemMessage(post) && canPost);
}));
const canPin = combineLatest([channelIsArchived, channelIsReadOnly]).pipe(switchMap(([isArchived, isReadOnly]) => {

View File

@@ -9,13 +9,13 @@ import DeviceInfo from 'react-native-device-info';
import Config from '@assets/config.json';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import SettingContainer from '@components/settings/container';
import SettingSeparator from '@components/settings/separator';
import AboutLinks from '@constants/about_links';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {t} from '@i18n';
import {popTopScreen} from '@screens/navigation';
import SettingContainer from '@screens/settings/setting_container';
import SettingSeparator from '@screens/settings/settings_separator';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';

View File

@@ -5,18 +5,17 @@ import React, {useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import {Alert, TouchableOpacity} from 'react-native';
import SettingContainer from '@components/settings/container';
import SettingOption from '@components/settings/option';
import SettingSeparator from '@components/settings/separator';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {popTopScreen} from '@screens/navigation';
import SettingSeparator from '@screens/settings/settings_separator';
import {deleteFileCache, getAllFilesInCachesDirectory, getFormattedFileSize} from '@utils/file';
import {preventDoubleTap} from '@utils/tap';
import {makeStyleSheetFromTheme} from '@utils/theme';
import SettingContainer from '../setting_container';
import SettingOption from '../setting_option';
import type {AvailableScreens} from '@typings/screens/navigation';
import type {ReadDirItem} from 'react-native-fs';

View File

@@ -4,6 +4,8 @@
import React, {useMemo} from 'react';
import {useIntl} from 'react-intl';
import SettingContainer from '@components/settings/container';
import SettingItem from '@components/settings/item';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
@@ -13,9 +15,6 @@ import {gotoSettingsScreen} from '@screens/settings/config';
import {preventDoubleTap} from '@utils/tap';
import {getUserTimezoneProps} from '@utils/user';
import SettingContainer from '../setting_container';
import SettingItem from '../setting_item';
import type UserModel from '@typings/database/models/servers/user';
import type {AvailableScreens} from '@typings/screens/navigation';

View File

@@ -5,17 +5,16 @@ import React, {useCallback, useState} from 'react';
import {useIntl} from 'react-intl';
import {savePreference} from '@actions/remote/preference';
import SettingBlock from '@components/settings/block';
import SettingContainer from '@components/settings/container';
import SettingOption from '@components/settings/option';
import SettingSeparator from '@components/settings/separator';
import {Preferences} from '@constants';
import {useServerUrl} from '@context/server';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useBackNavigation from '@hooks/navigate_back';
import {popTopScreen} from '@screens/navigation';
import SettingBlock from '../setting_block';
import SettingContainer from '../setting_container';
import SettingOption from '../setting_option';
import SettingSeparator from '../settings_separator';
import type {AvailableScreens} from '@typings/screens/navigation';
const CLOCK_TYPE = {

View File

@@ -4,18 +4,18 @@
import React, {useCallback, useState} from 'react';
import {useIntl} from 'react-intl';
import {savePreference} from '@actions/remote/preference';
import {handleCRTToggled, savePreference} from '@actions/remote/preference';
import SettingBlock from '@components/settings/block';
import SettingContainer from '@components/settings/container';
import SettingOption from '@components/settings/option';
import SettingSeparator from '@components/settings/separator';
import {Preferences} from '@constants';
import {useServerUrl} from '@context/server';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useBackNavigation from '@hooks/navigate_back';
import {t} from '@i18n';
import {popTopScreen} from '@screens/navigation';
import SettingBlock from '../setting_block';
import SettingContainer from '../setting_container';
import SettingOption from '../setting_option';
import SettingSeparator from '../settings_separator';
import EphemeralStore from '@store/ephemeral_store';
import type {AvailableScreens} from '@typings/screens/navigation';
@@ -37,7 +37,8 @@ const DisplayCRT = ({componentId, currentUserId, isCRTEnabled}: Props) => {
const close = () => popTopScreen(componentId);
const saveCRTPreference = useCallback(() => {
const saveCRTPreference = useCallback(async () => {
close();
if (isCRTEnabled !== isEnabled) {
const crtPreference: PreferenceType = {
category: Preferences.CATEGORIES.DISPLAY_SETTINGS,
@@ -45,9 +46,13 @@ const DisplayCRT = ({componentId, currentUserId, isCRTEnabled}: Props) => {
user_id: currentUserId,
value: isEnabled ? Preferences.COLLAPSED_REPLY_THREADS_ON : Preferences.COLLAPSED_REPLY_THREADS_OFF,
};
savePreference(serverUrl, [crtPreference]);
EphemeralStore.setEnablingCRT(true);
const {error} = await savePreference(serverUrl, [crtPreference]);
if (!error) {
handleCRTToggled(serverUrl);
}
}
close();
}, [isEnabled, isCRTEnabled, serverUrl]);
useBackNavigation(saveCRTPreference);

View File

@@ -4,10 +4,9 @@
import React from 'react';
import {useIntl} from 'react-intl';
import SettingOption from '@components/settings/option';
import SettingSeparator from '@components/settings/separator';
import {useTheme} from '@context/theme';
import SettingSeparator from '@screens/settings/settings_separator';
import SettingOption from '../setting_option';
const radioItemProps = {checkedBody: true};

View File

@@ -4,14 +4,13 @@
import React, {useCallback, useMemo} from 'react';
import {savePreference} from '@actions/remote/preference';
import SettingContainer from '@components/settings/container';
import {Preferences} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {popTopScreen} from '@screens/navigation';
import SettingContainer from '../setting_container';
import CustomTheme from './custom_theme';
import {ThemeTiles} from './theme_tiles';

View File

@@ -5,6 +5,9 @@ import React, {useCallback, useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {updateMe} from '@actions/remote/user';
import SettingContainer from '@components/settings/container';
import SettingOption from '@components/settings/option';
import SettingSeparator from '@components/settings/separator';
import {Screens} from '@constants';
import {useServerUrl} from '@context/server';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
@@ -14,10 +17,6 @@ import {preventDoubleTap} from '@utils/tap';
import {getDeviceTimezone} from '@utils/timezone';
import {getTimezoneRegion, getUserTimezoneProps} from '@utils/user';
import SettingContainer from '../setting_container';
import SettingOption from '../setting_option';
import SettingSeparator from '../settings_separator';
import type UserModel from '@typings/database/models/servers/user';
import type {AvailableScreens} from '@typings/screens/navigation';

View File

@@ -5,8 +5,8 @@ import React, {useCallback} from 'react';
import {TouchableOpacity, View, Text} from 'react-native';
import CompassIcon from '@components/compass_icon';
import SettingSeparator from '@components/settings/separator';
import {useTheme} from '@context/theme';
import SettingSeparator from '@screens/settings/settings_separator';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';

View File

@@ -7,6 +7,9 @@ import {useIntl} from 'react-intl';
import {fetchStatusInBatch, updateMe} from '@actions/remote/user';
import FloatingTextInput from '@components/floating_text_input_label';
import FormattedText from '@components/formatted_text';
import SettingContainer from '@components/settings/container';
import SettingOption from '@components/settings/option';
import SettingSeparator from '@components/settings/separator';
import {General} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
@@ -18,10 +21,6 @@ import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme}
import {typography} from '@utils/typography';
import {getNotificationProps} from '@utils/user';
import SettingContainer from '../setting_container';
import SettingOption from '../setting_option';
import SettingSeparator from '../settings_separator';
import type UserModel from '@typings/database/models/servers/user';
import type {AvailableScreens} from '@typings/screens/navigation';

View File

@@ -7,6 +7,10 @@ import {Text} from 'react-native';
import {savePreference} from '@actions/remote/preference';
import {updateMe} from '@actions/remote/user';
import SettingBlock from '@components/settings/block';
import SettingContainer from '@components/settings/container';
import SettingOption from '@components/settings/option';
import SettingSeparator from '@components/settings/separator';
import {Preferences} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
@@ -18,11 +22,6 @@ import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {getEmailInterval, getNotificationProps} from '@utils/user';
import SettingBlock from '../setting_block';
import SettingContainer from '../setting_container';
import SettingOption from '../setting_option';
import SettingSeparator from '../settings_separator';
import type UserModel from '@typings/database/models/servers/user';
import type {AvailableScreens} from '@typings/screens/navigation';

View File

@@ -7,6 +7,9 @@ import {Text} from 'react-native';
import {updateMe} from '@actions/remote/user';
import FloatingTextInput from '@components/floating_text_input_label';
import SettingBlock from '@components/settings/block';
import SettingOption from '@components/settings/option';
import SettingSeparator from '@components/settings/separator';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
@@ -18,10 +21,6 @@ import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme}
import {typography} from '@utils/typography';
import {getNotificationProps} from '@utils/user';
import SettingBlock from '../setting_block';
import SettingOption from '../setting_option';
import SettingSeparator from '../settings_separator';
import type UserModel from '@typings/database/models/servers/user';
import type {AvailableScreens} from '@typings/screens/navigation';

View File

@@ -3,7 +3,7 @@
import React from 'react';
import SettingContainer from '../setting_container';
import SettingContainer from '@components/settings/container';
import MentionSettings from './mention_settings';

View File

@@ -4,12 +4,11 @@
import React, {Dispatch, SetStateAction} from 'react';
import {useIntl} from 'react-intl';
import SettingBlock from '@components/settings/block';
import SettingOption from '@components/settings/option';
import SettingSeparator from '@components/settings/separator';
import {t} from '@i18n';
import SettingBlock from '../setting_block';
import SettingOption from '../setting_option';
import SettingSeparator from '../settings_separator';
const replyHeaderText = {
id: t('notification_settings.mention.reply'),
defaultMessage: 'Send reply notifications for',

View File

@@ -5,15 +5,14 @@ import React, {useCallback, useMemo, useState} from 'react';
import {Platform} from 'react-native';
import {updateMe} from '@actions/remote/user';
import SettingContainer from '@components/settings/container';
import SettingSeparator from '@components/settings/separator';
import {useServerUrl} from '@context/server';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useBackNavigation from '@hooks/navigate_back';
import {popTopScreen} from '@screens/navigation';
import SettingSeparator from '@screens/settings/settings_separator';
import {getNotificationProps} from '@utils/user';
import SettingContainer from '../setting_container';
import MobileSendPush from './push_send';
import MobilePushStatus from './push_status';
import MobilePushThread from './push_thread';

View File

@@ -5,15 +5,14 @@ import React from 'react';
import {useIntl} from 'react-intl';
import FormattedText from '@components/formatted_text';
import SettingBlock from '@components/settings/block';
import SettingOption from '@components/settings/option';
import SettingSeparator from '@components/settings/separator';
import {useTheme} from '@context/theme';
import {t} from '@i18n';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import SettingBlock from '../setting_block';
import SettingOption from '../setting_option';
import SettingSeparator from '../settings_separator';
const headerText = {
id: t('notification_settings.send_notification.about'),
defaultMessage: 'Notify me about...',

View File

@@ -4,12 +4,11 @@
import React from 'react';
import {useIntl} from 'react-intl';
import SettingBlock from '@components/settings/block';
import SettingOption from '@components/settings/option';
import SettingSeparator from '@components/settings/separator';
import {t} from '@i18n';
import SettingBlock from '../setting_block';
import SettingOption from '../setting_option';
import SettingSeparator from '../settings_separator';
const headerText = {
id: t('notification_settings.mobile.trigger_push'),
defaultMessage: 'Trigger push notifications when...',

View File

@@ -4,12 +4,11 @@
import React from 'react';
import {useIntl} from 'react-intl';
import SettingBlock from '@components/settings/block';
import SettingOption from '@components/settings/option';
import SettingSeparator from '@components/settings/separator';
import {t} from '@i18n';
import SettingBlock from '../setting_block';
import SettingOption from '../setting_option';
import SettingSeparator from '../settings_separator';
const headerText = {
id: t('notification_settings.push_threads.replies'),
defaultMessage: 'Thread replies',

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