Compare commits

...

53 Commits

Author SHA1 Message Date
Shivashis Padhi
b0111a59a7 Revert "[MM-47384] Make openid, google, office365 oauth free (#6755)"
This reverts commit 4bacba92bb.
2023-01-24 13:21:58 +05:30
Anurag Shivarathri
eb46a6aeff [MM-47548 Gekidou] Data Retention Implementation (#6732)
* Fetch & Save granular data retention policies through REST

* Init Data cleanup

* Run the clean up

* Deleting posts in patches and running across other servers

* fetch on graphql & refactor

* Feedback changes

* Added try catch for deletePosts function

* Feedback changes

* Changed to 'for of' loop

* Misc

* app/actions

* Date cutoff fox

* Prevent showing loading bar when request fails

Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
2023-01-21 16:04:19 +05:30
Avinash Lingaloo
24ec88096c Bump app build number to 453 (#6995) 2023-01-20 21:27:41 +04:00
Avinash Lingaloo
82c5244589 MM-44652 - Fix long team name (#6943)
* fix long team name

* minor code refactor

* adds a comment to explain the 85%

* update snapshots
2023-01-20 21:17:01 +04:00
Daniel Espino García
9c19e4d04a Remove uneeded database migrations (#6985) 2023-01-19 16:56:30 +01:00
Daniel Espino García
2597e06ac2 Add webhook image to notification in missing scenarios (#6971)
* Add webhook image to notification in missing scenarios

* Improve urlOverride check
2023-01-19 10:53:09 +01:00
Jason Frerich
88afe7db91 [MM-48282] When user selects open in channel, dismiss the bottom sheet (#6956) 2023-01-18 16:26:05 -06:00
Daniel Espino García
0222504cc9 Fix e2e login error (#6978)
* Fix e2e login error

* fix lint
2023-01-17 14:02:39 +01:00
kaakaa
e14d637f3a Translated using Weblate (Japanese)
Currently translated at 100.0% (941 of 941 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/ja/
2023-01-16 18:51:50 +02:00
kaakaa
2fadbe15e0 Translated using Weblate (Japanese)
Currently translated at 98.8% (930 of 941 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/ja/
2023-01-16 18:51:50 +02:00
Matthew Williams
d9aa196dfb Translated using Weblate (English (Australia))
Currently translated at 99.7% (939 of 941 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/en_AU/
2023-01-16 18:51:50 +02:00
Konstantin
e05e722f64 Translated using Weblate (Russian)
Currently translated at 100.0% (941 of 941 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/ru/
2023-01-16 18:51:50 +02:00
master7
29252e2a84 Translated using Weblate (Polish)
Currently translated at 100.0% (941 of 941 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/pl/
2023-01-16 18:51:50 +02:00
Tom De Moor
4ee169e458 Translated using Weblate (Dutch)
Currently translated at 100.0% (941 of 941 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/nl/
2023-01-16 18:51:50 +02:00
Cédric Stocké
6cb4dc4cc0 Translated using Weblate (French)
Currently translated at 100.0% (941 of 941 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/fr/
2023-01-16 18:51:50 +02:00
Cédric Stocké
a8ef1a5bed Translated using Weblate (Spanish)
Currently translated at 100.0% (941 of 941 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/es/
2023-01-16 18:51:50 +02:00
jprusch
c5fa6d0827 Translated using Weblate (German)
Currently translated at 100.0% (941 of 941 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/de/
2023-01-16 18:51:50 +02:00
Cédric Stocké
2ebb34266f Translated using Weblate (French)
Currently translated at 100.0% (938 of 938 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/fr/
2023-01-16 18:51:50 +02:00
Cédric Stocké
79f665f8eb Translated using Weblate (Spanish)
Currently translated at 89.8% (843 of 938 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/es/
2023-01-16 18:51:50 +02:00
Cédric Stocké
a284770861 Translated using Weblate (French)
Currently translated at 91.2% (856 of 938 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/fr/
2023-01-16 18:51:50 +02:00
Cédric Stocké
363313e530 Translated using Weblate (French)
Currently translated at 90.8% (852 of 938 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/fr/
2023-01-16 18:51:50 +02:00
Cédric Stocké
1eb5c958ec Translated using Weblate (French)
Currently translated at 87.7% (823 of 938 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/fr/
2023-01-16 18:51:50 +02:00
Hosted Weblate
cd6ae1692d 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-01-16 18:51:50 +02:00
Cédric Stocké
c0edb4ecd4 Translated using Weblate (French)
Currently translated at 66.8% (628 of 939 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/fr/
2023-01-16 18:51:50 +02:00
Elias Nahum
d417b95643 Bump app build number to 452 (#6974) 2023-01-13 21:11:35 +02:00
Elias Nahum
49fc180982 Fix the caret position when using the search phrase modifier (#6972) 2023-01-13 21:06:16 +02:00
Daniel Espino García
b191154db9 Clean todos (#6897)
* Clean todos

* Remove DatabaseHelper folder
2023-01-12 14:25:39 +01:00
Elias Nahum
612fd5022f New UI for Emoji picker (#6933)
* BottomSheet migration to react-native-bottom-sheet

* Refactor Emoji picker to use bottom sheet

* Add skin selector

* Add Emoji Skin Tone tutorial

* add selected indicator to tone selector

* feedback review

* show tooltip after 750ms

* ux feedback review

* ux feedback review #2

* Hide emoji picker scroll indicator
2023-01-12 13:38:44 +02:00
Elias Nahum
9411dbd669 Bump app build number to 451 (#6967) 2023-01-12 13:02:47 +02:00
Anurag Shivarathri
c8ded6ef3c Fix (#6960) 2023-01-12 12:54:31 +02:00
Elias Nahum
bb351c7376 Fix some issues found by Sentry (#6962) 2023-01-12 11:01:59 +02:00
Elias Nahum
247d8371d9 Add String description in log arguments (#6961) 2023-01-12 11:01:19 +02:00
Elias Nahum
cab863d62f Add back wrongly removed Ephemeral.theme assignment (#6959) 2023-01-12 10:58:05 +02:00
Joseph Baylon
5125db1d69 Detox Reporting: Fix artifact link (#6945)
* Detox Reporting: Fix artifact link

* Simplified s3Folder
2023-01-11 13:55:25 -08:00
Elias Nahum
0380382b77 fix schema test 2023-01-11 22:22:11 +02:00
Elias Nahum
a10357e03c fix ci 2023-01-11 22:12:49 +02:00
Elias Nahum
4199b13843 Bump build number to 450 (#6950)
* Fix upgrade path

* Introduce Upgrade helper

* Reset server database schema version to 1

* Enable release builds on the CI

* Bump build number to 450
2023-01-11 21:40:49 +02:00
Daniel Espino García
8edf128d59 Fix code syntax highlight on full screen view (#6944) 2023-01-11 21:40:01 +02:00
Daniel Espino García
6082a6a790 Fix connection banner showing when not needed (#6948)
* Fix connection banner showing when not needed

* Fix some issues and some refactoring
2023-01-11 21:37:09 +02:00
Kaya Zeren
5ea1965ae7 Translated using Weblate (Turkish)
Currently translated at 93.6% (879 of 939 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/tr/
2023-01-11 11:28:19 +02:00
정성근
1362d4a90b Translated using Weblate (Korean)
Currently translated at 59.2% (556 of 939 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/ko/
2023-01-11 11:28:19 +02:00
Elias Nahum
e6a6e25799 Fix navigation theming (#6946) 2023-01-11 09:53:09 +02:00
Elias Nahum
252dc1e3c8 check for analytics enabled 2023-01-09 11:13:11 +02:00
Elias Nahum
69e27ae812 Bump app build number to 449 (#6940) 2023-01-07 18:42:27 +02:00
Elias Nahum
b653b86443 fix Sentry import 2023-01-07 18:35:31 +02:00
Elias Nahum
107416109a Bump app build number to 448 (#6939) 2023-01-07 13:55:27 +02:00
Elias Nahum
c1abccf3ed update deps (#6938) 2023-01-07 13:43:33 +02:00
Elias Nahum
977bcd2b42 Fix sentry inner crash and have watermelon report in which table it cannot update records with pending changes 2023-01-07 12:49:48 +02:00
Elias Nahum
68ec90df90 Bump app build number to 447 (#6937) 2023-01-06 21:46:07 +02:00
Elias Nahum
c8d9758f18 CI fixes (#6936)
* CI fixes

* Fix fastlane script
2023-01-06 21:41:03 +02:00
Elias Nahum
8097ee300d Fix crashes found in NotificationService (#6935) 2023-01-06 21:35:40 +02:00
Elias Nahum
c2aaef2e14 Preserve new message line on WS reconnect (#6934) 2023-01-06 13:38:40 +04:00
Joseph Baylon
d465edefc3 Detox Maintenance: Skip tests that are unstable and cleanup known issues (#6931)
* Detox Reporting: Prepare for release and main

* Fix lint in switch

* Detox Maintenance: Skip unstable tests

* Skipped unstable and known issue tests

* Fix inline latex test

* Remove skipped from failures

* Remove unnecessary report failure

* Remove assert import

* Remove FAILURE_MESSAGE

Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
2023-01-05 14:43:09 -08:00
151 changed files with 3427 additions and 1322 deletions

View File

@@ -39,7 +39,7 @@ commands:
steps:
- add_ssh_keys:
fingerprints:
- "59:4d:99:5e:1c:6d:30:36:6d:60:76:88:ff:a7:ab:63"
- "03:1c:a7:07:35:bc:57:e4:1d:6c:e1:2c:4b:be:09:6d"
- run:
name: Clone the mobile private repo
command: git clone git@github.com:mattermost/mattermost-mobile-private.git ~/mattermost-mobile-private
@@ -309,13 +309,13 @@ jobs:
- save:
filename: "*.apk"
# build-android-release:
# executor: android
# steps:
# - build-android
# - persist
# - save:
# filename: "*.apk"
build-android-release:
executor: android
steps:
- build-android
- persist
- save:
filename: "*.apk"
build-android-pr:
executor: android
@@ -326,27 +326,27 @@ jobs:
- save:
filename: "*.apk"
# build-android-unsigned:
# executor: android
# steps:
# - checkout:
# path: ~/mattermost-mobile
# - npm-dependencies
# - assets
# - fastlane-dependencies:
# for: android
# - gradle-dependencies
# - run:
# name: Jetify Android libraries
# command: ./node_modules/.bin/jetify
# - run:
# working_directory: fastlane
# name: Run fastlane to build unsigned android
# no_output_timeout: 30m
# command: bundle exec fastlane android unsigned
# - persist
# - save:
# filename: "*.apk"
build-android-unsigned:
executor: android
steps:
- checkout:
path: ~/mattermost-mobile
- npm-dependencies
- assets
- fastlane-dependencies:
for: android
- gradle-dependencies
- run:
name: Jetify Android libraries
command: ./node_modules/.bin/jetify
- run:
working_directory: fastlane
name: Run fastlane to build unsigned android
no_output_timeout: 30m
command: bundle exec fastlane android unsigned
- persist
- save:
filename: "*.apk"
build-ios-beta:
executor:
@@ -358,13 +358,13 @@ jobs:
- save:
filename: "*.ipa"
# build-ios-release:
# executor: ios
# steps:
# - build-ios
# - persist
# - save:
# filename: "*.ipa"
build-ios-release:
executor: ios
steps:
- build-ios
- persist
- save:
filename: "*.ipa"
build-ios-pr:
executor: ios
@@ -375,63 +375,64 @@ jobs:
- save:
filename: "*.ipa"
# build-ios-unsigned:
# executor: ios
# steps:
# - checkout:
# path: ~/mattermost-mobile
# - npm-dependencies
# - pods-dependencies
# - assets
# - fastlane-dependencies:
# for: ios
# - run:
# working_directory: fastlane
# name: Run fastlane to build unsigned iOS
# no_output_timeout: 30m
# command: |
# HOMEBREW_NO_AUTO_UPDATE=1 brew install watchman
# bundle exec fastlane ios unsigned
# - persist_to_workspace:
# root: ~/
# paths:
# - mattermost-mobile/*.ipa
# - save:
# filename: "*.ipa"
build-ios-unsigned:
executor: ios
steps:
- checkout:
path: ~/mattermost-mobile
- npm-dependencies
- pods-dependencies
- assets
- fastlane-dependencies:
for: ios
- run:
working_directory: fastlane
name: Run fastlane to build unsigned iOS
no_output_timeout: 30m
command: |
HOMEBREW_NO_AUTO_UPDATE=1 brew install watchman
bundle exec fastlane ios unsigned
- persist_to_workspace:
root: ~/
paths:
- mattermost-mobile/*.ipa
- save:
filename: "*.ipa"
# build-ios-simulator:
# executor: ios
# steps:
# - checkout:
# path: ~/mattermost-mobile
# - npm-dependencies
# - pods-dependencies
# - assets
# - fastlane-dependencies:
# for: ios
# - run:
# working_directory: fastlane
# name: Run fastlane to build unsigned x86_64 iOS app for iPhone simulator
# no_output_timeout: 30m
# command: |
# HOMEBREW_NO_AUTO_UPDATE=1 brew install watchman
# bundle exec fastlane ios simulator
# - persist_to_workspace:
# root: ~/
# paths:
# - mattermost-mobile/Mattermost-simulator-x86_64.app.zip
# - save:
# filename: "Mattermost-simulator-x86_64.app.zip"
build-ios-simulator:
executor: ios
steps:
- checkout:
path: ~/mattermost-mobile
- npm-dependencies
- pods-dependencies
- assets
- fastlane-dependencies:
for: ios
- run:
working_directory: fastlane
name: Run fastlane to build unsigned x86_64 iOS app for iPhone simulator
no_output_timeout: 30m
command: |
HOMEBREW_NO_AUTO_UPDATE=1 brew install watchman
bundle exec fastlane ios simulator
- persist_to_workspace:
root: ~/
paths:
- mattermost-mobile/Mattermost-simulator-x86_64.app.zip
- save:
filename: "Mattermost-simulator-x86_64.app.zip"
# deploy-android-release:
# executor:
# name: android
# resource_class: medium
# steps:
# - deploy-to-store:
# task: "Deploy to Google Play"
# target: android
# file: "*.apk"
deploy-android-release:
executor:
name: android
resource_class: medium
steps:
- deploy-to-store:
task: "Deploy to Google Play"
target: android
file: "*.apk"
env: "SUPPLY_TRACK=beta"
deploy-android-beta:
executor:
@@ -444,13 +445,14 @@ jobs:
file: "*.apk"
env: "SUPPLY_TRACK=alpha"
# deploy-ios-release:
# executor: ios
# steps:
# - deploy-to-store:
# task: "Deploy to TestFlight"
# target: ios
# file: "*.ipa"
deploy-ios-release:
executor: ios
steps:
- deploy-to-store:
task: "Deploy to TestFlight"
target: ios
file: "*.ipa"
env: ""
deploy-ios-beta:
executor: ios
@@ -461,17 +463,17 @@ jobs:
file: "*.ipa"
env: ""
# github-release:
# executor:
# name: android
# resource_class: medium
# steps:
# - attach_workspace:
# at: ~/
# - run:
# name: Create GitHub release
# working_directory: fastlane
# command: bundle exec fastlane github
github-release:
executor:
name: android
resource_class: medium
steps:
- attach_workspace:
at: ~/
- run:
name: Create GitHub release
working_directory: fastlane
command: bundle exec fastlane github
workflows:
version: 2
@@ -483,26 +485,24 @@ workflows:
# requires:
# - test
# - build-android-release:
# context: mattermost-mobile-android-release
# requires:
# - test
# filters:
# branches:
# only:
# - /^build-\d+$/
# - /^build-android-\d+$/
# - /^build-android-release-\d+$/
# - deploy-android-release:
# context: mattermost-mobile-android-release
# requires:
# - build-android-release
# filters:
# branches:
# only:
# - /^build-\d+$/
# - /^build-android-\d+$/
# - /^build-android-release-\d+$/
- build-android-release:
context: mattermost-mobile-android-release
requires:
- test
filters:
branches:
only:
- /^build-release-\d+$/
- /^build-android-release-\d+$/
- deploy-android-release:
context: mattermost-mobile-android-release
requires:
- build-android-release
filters:
branches:
only:
- /^build-release-\d+$/
- /^build-android-release-\d+$/
- build-android-beta:
context: mattermost-mobile-android-beta
@@ -523,26 +523,24 @@ workflows:
- /^build-android-\d+$/
- /^build-android-beta-\d+$/
# - build-ios-release:
# context: mattermost-mobile-ios-release
# requires:
# - test
# filters:
# branches:
# only:
# - /^build-\d+$/
# - /^build-ios-\d+$/
# - /^build-ios-release-\d+$/
# - deploy-ios-release:
# context: mattermost-mobile-ios-release
# requires:
# - build-ios-release
# filters:
# branches:
# only:
# - /^build-\d+$/
# - /^build-ios-\d+$/
# - /^build-ios-release-\d+$/
- build-ios-release:
context: mattermost-mobile-ios-release
requires:
- test
filters:
branches:
only:
- /^build-release-\d+$/
- /^build-ios-release-\d+$/
- deploy-ios-release:
context: mattermost-mobile-ios-release
requires:
- build-ios-release
filters:
branches:
only:
- /^build-release-\d+$/
- /^build-ios-release-\d+$/
- build-ios-beta:
context: mattermost-mobile-ios-beta
@@ -578,43 +576,41 @@ workflows:
branches:
only: /^(build|ios)-pr-.*/
# - build-android-unsigned:
# context: mattermost-mobile-unsigned
# requires:
# - test
# filters:
# tags:
# only: /^v(\d+\.)(\d+\.)(\d+)(.*)?$/
# branches:
# only: unsigned
# - build-ios-unsigned:
# context: mattermost-mobile-unsigned
# requires:
# - test
# filters:
# tags:
# only: /^v(\d+\.)(\d+\.)(\d+)(.*)?$/
# branches:
# only: unsigned
# - build-ios-simulator:
# context: mattermost-mobile-unsigned
# requires:
# - test
# filters:
# branches:
# only:
# - /^build-\d+$/
# - /^build-ios-\d+$/
# - /^build-ios-beta-\d+$/
# - /^build-ios-sim-\d+$/
- build-android-unsigned:
context: mattermost-mobile-unsigned
requires:
- test
filters:
tags:
only: /^v(\d+\.)(\d+\.)(\d+)(.*)?$/
branches:
only: unsigned
- build-ios-unsigned:
context: mattermost-mobile-unsigned
requires:
- test
filters:
tags:
only: /^v(\d+\.)(\d+\.)(\d+)(.*)?$/
branches:
only: unsigned
- build-ios-simulator:
context: mattermost-mobile-unsigned
requires:
- test
filters:
branches:
only:
- /^build-\d+$/
- /^build-ios-sim-\d+$/
# - github-release:
# context: mattermost-mobile-unsigned
# requires:
# - build-android-unsigned
# - build-ios-unsigned
# filters:
# tags:
# only: /^v(\d+\.)(\d+\.)(\d+)(.*)?$/
# branches:
# only: unsigned
- github-release:
context: mattermost-mobile-unsigned
requires:
- build-android-unsigned
- build-ios-unsigned
filters:
tags:
only: /^v(\d+\.)(\d+\.)(\d+)(.*)?$/
branches:
only: unsigned

View File

@@ -145,7 +145,7 @@ android {
applicationId "com.mattermost.rnbeta"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 446
versionCode 453
versionName "2.0.0"
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'

View File

@@ -58,6 +58,7 @@ public class CustomPushNotificationHelper {
String senderId = bundle.getString("sender_id");
String serverUrl = bundle.getString("server_url");
String type = bundle.getString("type");
String urlOverride = bundle.getString("override_icon_url");
if (senderId == null) {
senderId = "sender_id";
}
@@ -74,7 +75,7 @@ public class CustomPushNotificationHelper {
if (serverUrl != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) {
try {
Bitmap avatar = userAvatar(context, serverUrl, senderId, null);
Bitmap avatar = userAvatar(context, serverUrl, senderId, urlOverride);
if (avatar != null) {
sender.setIcon(IconCompat.createWithBitmap(avatar));
}
@@ -266,6 +267,7 @@ public class CustomPushNotificationHelper {
final String senderId = "me";
final String serverUrl = bundle.getString("server_url");
final String type = bundle.getString("type");
String urlOverride = bundle.getString("override_icon_url");
Person.Builder sender = new Person.Builder()
.setKey(senderId)
@@ -273,7 +275,7 @@ public class CustomPushNotificationHelper {
if (serverUrl != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) {
try {
Bitmap avatar = userAvatar(context, serverUrl, "me", null);
Bitmap avatar = userAvatar(context, serverUrl, "me", urlOverride);
if (avatar != null) {
sender.setIcon(IconCompat.createWithBitmap(avatar));
}
@@ -426,7 +428,7 @@ public class CustomPushNotificationHelper {
final OkHttpClient client = new OkHttpClient();
Request request;
String url;
if (urlOverride != null) {
if (!TextUtils.isEmpty(urlOverride)) {
request = new Request.Builder().url(urlOverride).build();
Log.i("ReactNative", String.format("Fetch override profile image %s", urlOverride));
} else {

View File

@@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Tutorial} from '@constants';
import {GLOBAL_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {logError} from '@utils/log';
@@ -22,16 +23,20 @@ export const storeDeviceToken = async (token: string, prepareRecordsOnly = false
return storeGlobal(GLOBAL_IDENTIFIERS.DEVICE_TOKEN, token, prepareRecordsOnly);
};
export const storeMultiServerTutorial = async (prepareRecordsOnly = false) => {
return storeGlobal(GLOBAL_IDENTIFIERS.MULTI_SERVER_TUTORIAL, 'true', prepareRecordsOnly);
};
export const storeOnboardingViewedValue = async (value = true) => {
return storeGlobal(GLOBAL_IDENTIFIERS.ONBOARDING, value, false);
};
export const storeMultiServerTutorial = async (prepareRecordsOnly = false) => {
return storeGlobal(Tutorial.MULTI_SERVER, 'true', prepareRecordsOnly);
};
export const storeProfileLongPressTutorial = async (prepareRecordsOnly = false) => {
return storeGlobal(GLOBAL_IDENTIFIERS.PROFILE_LONG_PRESS_TUTORIAL, 'true', prepareRecordsOnly);
return storeGlobal(Tutorial.PROFILE_LONG_PRESS, 'true', prepareRecordsOnly);
};
export const storeSkinEmojiSelectorTutorial = async (prepareRecordsOnly = false) => {
return storeGlobal(Tutorial.EMOJI_SKIN_SELECTOR, 'true', prepareRecordsOnly);
};
export const storeDontAskForReview = async (prepareRecordsOnly = false) => {

View File

@@ -76,7 +76,7 @@ export async function switchToChannel(serverUrl: string, channelId: string, team
}
models = (await Promise.all(modelPromises)).flat();
const {member: viewedAt} = await markChannelAsViewed(serverUrl, channelId, true);
const {member: viewedAt} = await markChannelAsViewed(serverUrl, channelId, false, true);
if (viewedAt) {
models.push(viewedAt);
}
@@ -160,7 +160,7 @@ export async function selectAllMyChannelIds(serverUrl: string) {
}
}
export async function markChannelAsViewed(serverUrl: string, channelId: string, prepareRecordsOnly = false) {
export async function markChannelAsViewed(serverUrl: string, channelId: string, onlyCounts = false, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const member = await getMyChannel(database, channelId);
@@ -172,8 +172,10 @@ export async function markChannelAsViewed(serverUrl: string, channelId: string,
m.isUnread = false;
m.mentionsCount = 0;
m.manuallyUnread = false;
m.viewedAt = member.lastViewedAt;
m.lastViewedAt = Date.now();
if (!onlyCounts) {
m.viewedAt = member.lastViewedAt;
m.lastViewedAt = Date.now();
}
});
PushNotifications.removeChannelNotifications(serverUrl, channelId);
if (!prepareRecordsOnly) {

View File

@@ -3,6 +3,7 @@
import {fetchPostAuthors} from '@actions/remote/post';
import {ActionType, Post} from '@constants';
import {MM_TABLES} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getPostById, prepareDeletePost, queryPostsById} from '@queries/servers/post';
import {getCurrentUserId} from '@queries/servers/system';
@@ -18,6 +19,8 @@ import type MyChannelModel from '@typings/database/models/servers/my_channel';
import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
const {SERVER: {DRAFT, FILE, POST, POSTS_IN_THREAD, REACTION, THREAD, THREAD_PARTICIPANT, THREADS_IN_TEAM}} = MM_TABLES;
export const sendAddToChannelEphemeralPost = async (serverUrl: string, user: UserModel, addedUsernames: string[], messages: string[], channeId: string, postRootId = '') => {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
@@ -244,3 +247,33 @@ export async function getPosts(serverUrl: string, ids: string[]) {
return [];
}
}
export async function deletePosts(serverUrl: string, postIds: string[]) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const postsFormatted = `'${postIds.join("','")}'`;
await database.write(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
return database.adapter.unsafeExecute({
sqls: [
[`DELETE FROM ${POST} where id IN (${postsFormatted})`, []],
[`DELETE FROM ${REACTION} where post_id IN (${postsFormatted})`, []],
[`DELETE FROM ${FILE} where post_id IN (${postsFormatted})`, []],
[`DELETE FROM ${DRAFT} where root_id IN (${postsFormatted})`, []],
[`DELETE FROM ${POSTS_IN_THREAD} where root_id IN (${postsFormatted})`, []],
[`DELETE FROM ${THREAD} where id IN (${postsFormatted})`, []],
[`DELETE FROM ${THREAD_PARTICIPANT} where thread_id IN (${postsFormatted})`, []],
[`DELETE FROM ${THREADS_IN_TEAM} where thread_id IN (${postsFormatted})`, []],
],
});
});
return {error: false};
} catch (error) {
return {error};
}
}

View File

@@ -1,14 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q} from '@nozbe/watermelondb';
import deepEqual from 'deep-equal';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getServerCredentials} from '@init/credentials';
import {getConfig, getLicense} from '@queries/servers/system';
import {queryAllChannelsForTeam} from '@queries/servers/channel';
import {getConfig, getLicense, getGlobalDataRetentionPolicy, getGranularDataRetentionPolicies, getLastGlobalDataRetentionRun, getIsDataRetentionEnabled} from '@queries/servers/system';
import {logError} from '@utils/log';
import {deletePosts} from './post';
import type {DataRetentionPoliciesRequest} from '@actions/remote/systems';
import type PostModel from '@typings/database/models/servers/post';
const {SERVER: {POST}} = MM_TABLES;
export async function storeConfigAndLicense(serverUrl: string, config: ClientConfig, license: ClientLicense) {
try {
// If we have credentials for this server then update the values in the database
@@ -74,6 +83,155 @@ export async function storeConfig(serverUrl: string, config: ClientConfig | unde
return [];
}
export async function storeDataRetentionPolicies(serverUrl: string, data: DataRetentionPoliciesRequest, prepareRecordsOnly = false) {
try {
const {globalPolicy, teamPolicies, channelPolicies} = data;
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const systems: IdValue[] = [{
id: SYSTEM_IDENTIFIERS.DATA_RETENTION_POLICIES,
value: globalPolicy || {},
}, {
id: SYSTEM_IDENTIFIERS.GRANULAR_DATA_RETENTION_POLICIES,
value: {
team: teamPolicies || [],
channel: channelPolicies || [],
},
}];
return operator.handleSystem({
systems,
prepareRecordsOnly,
});
} catch {
return [];
}
}
export async function updateLastDataRetentionRun(serverUrl: string, value?: number, prepareRecordsOnly = false) {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const systems: IdValue[] = [{
id: SYSTEM_IDENTIFIERS.LAST_DATA_RETENTION_RUN,
value: value || Date.now(),
}];
return operator.handleSystem({systems, prepareRecordsOnly});
} catch (error) {
logError('Failed updateLastDataRetentionRun', error);
return {error};
}
}
export async function dataRetentionCleanup(serverUrl: string) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const isDataRetentionEnabled = await getIsDataRetentionEnabled(database);
if (!isDataRetentionEnabled) {
return {error: undefined};
}
const lastRunAt = await getLastGlobalDataRetentionRun(database);
const lastCleanedToday = new Date(lastRunAt).toDateString() === new Date().toDateString();
// Do not run if clean up is already done today
if (lastRunAt && lastCleanedToday) {
return {error: undefined};
}
const globalPolicy = await getGlobalDataRetentionPolicy(database);
const granularPoliciesData = await getGranularDataRetentionPolicies(database);
// Get global data retention cutoff
let globalRetentionCutoff = 0;
if (globalPolicy?.message_deletion_enabled) {
globalRetentionCutoff = globalPolicy.message_retention_cutoff;
}
// Get Granular data retention policies
let teamPolicies: TeamDataRetentionPolicy[] = [];
let channelPolicies: ChannelDataRetentionPolicy[] = [];
if (granularPoliciesData) {
teamPolicies = granularPoliciesData.team;
channelPolicies = granularPoliciesData.channel;
}
const channelsCutoffs: {[key: string]: number} = {};
// Get channel level cutoff from team policies
for await (const teamPolicy of teamPolicies) {
const {team_id, post_duration} = teamPolicy;
const channelIds = await queryAllChannelsForTeam(database, team_id).fetchIds();
if (channelIds.length) {
const cutoff = getDataRetentionPolicyCutoff(post_duration);
channelIds.forEach((channelId) => {
channelsCutoffs[channelId] = cutoff;
});
}
}
// Get channel level cutoff from channel policies
channelPolicies.forEach(({channel_id, post_duration}) => {
channelsCutoffs[channel_id] = getDataRetentionPolicyCutoff(post_duration);
});
const conditions = [];
const channelIds = Object.keys(channelsCutoffs);
if (channelIds.length) {
// Fetch posts by channel level cutoff
for (const channelId of channelIds) {
const cutoff = channelsCutoffs[channelId];
conditions.push(`(channel_id='${channelId}' AND create_at < ${cutoff})`);
}
// Fetch posts by global cutoff which are not already fetched by channel level cutoff
conditions.push(`(channel_id NOT IN ('${channelIds.join("','")}') AND create_at < ${globalRetentionCutoff})`);
} else {
conditions.push(`create_at < ${globalRetentionCutoff}`);
}
const postIds = await database.get<PostModel>(POST).query(
Q.unsafeSqlQuery(`SELECT * FROM ${POST} where ${conditions.join(' OR ')}`),
).fetchIds();
if (postIds.length) {
const batchSize = 1000;
const deletePromises = [];
for (let i = 0; i < postIds.length; i += batchSize) {
const batch = postIds.slice(i, batchSize);
deletePromises.push(
deletePosts(serverUrl, batch),
);
}
const deleteResult = await Promise.all(deletePromises);
for (const {error} of deleteResult) {
if (error) {
return {error};
}
}
}
await updateLastDataRetentionRun(serverUrl);
return {error: undefined};
} catch (error) {
logError('An error occurred while performing data retention cleanup', error);
return {error};
}
}
// Returns cutoff time based on the policy's post_duration
function getDataRetentionPolicyCutoff(postDuration: number) {
const periodDate = new Date();
periodDate.setDate(periodDate.getDate() - postDuration);
periodDate.setHours(0);
periodDate.setMinutes(0);
periodDate.setSeconds(0);
return periodDate.getTime();
}
export async function setLastServerVersionCheck(serverUrl: string, reset = false) {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);

View File

@@ -1,13 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {setLastServerVersionCheck} from '@actions/local/systems';
import {dataRetentionCleanup, 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 {deleteV1Data} from '@utils/file';
import {isTablet} from '@utils/helpers';
import {logInfo} from '@utils/log';
import {handleEntryAfterLoadNavigation, registerDeviceToken, syncOtherServers, verifyPushProxy} from './common';
@@ -26,10 +27,13 @@ export async function appEntry(serverUrl: string, since = 0, isUpgrade = false)
}
}
// Run data retention cleanup
await dataRetentionCleanup(serverUrl);
// clear lastUnreadChannelId
const removeLastUnreadChannelId = await prepareCommonSystemValues(operator, {lastUnreadChannelId: ''});
if (removeLastUnreadChannelId) {
operator.batchRecords(removeLastUnreadChannelId);
await operator.batchRecords(removeLastUnreadChannelId);
}
const {database} = operator;
@@ -47,7 +51,12 @@ export async function appEntry(serverUrl: string, since = 0, isUpgrade = false)
const {models, initialTeamId, initialChannelId, prefData, teamData, chData, meData} = entryData;
if (isUpgrade && meData?.user) {
const me = await prepareCommonSystemValues(operator, {currentUserId: meData.user.id});
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);
}
@@ -84,8 +93,8 @@ export async function upgradeEntry(serverUrl: string) {
const error = configAndLicense.error || entryData.error;
if (!error) {
DatabaseManager.updateServerIdentifier(serverUrl, configAndLicense.config!.DiagnosticId);
DatabaseManager.setActiveServerDatabase(serverUrl);
await DatabaseManager.updateServerIdentifier(serverUrl, configAndLicense.config!.DiagnosticId);
await DatabaseManager.setActiveServerDatabase(serverUrl);
deleteV1Data();
}

View File

@@ -3,6 +3,7 @@
import {Database, Model} from '@nozbe/watermelondb';
import {dataRetentionCleanup} from '@actions/local/systems';
import {fetchMissingDirectChannelsInfo, fetchMyChannelsForTeam, handleKickFromChannel, MyChannelsRequest} from '@actions/remote/channel';
import {fetchGroupsForMember} from '@actions/remote/groups';
import {fetchPostsForUnreadChannels} from '@actions/remote/post';
@@ -378,7 +379,9 @@ export const syncOtherServers = async (serverUrl: string) => {
for (const server of servers) {
if (server.url !== serverUrl && server.lastActiveAt > 0) {
registerDeviceToken(server.url);
syncAllChannelMembersAndThreads(server.url);
syncAllChannelMembersAndThreads(server.url).then(() => {
dataRetentionCleanup(server.url);
});
autoUpdateTimezone(server.url);
}
}

View File

@@ -7,6 +7,7 @@ import {storeConfigAndLicense} from '@actions/local/systems';
import {MyChannelsRequest} from '@actions/remote/channel';
import {fetchGroupsForMember} from '@actions/remote/groups';
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';
@@ -18,7 +19,7 @@ import {selectDefaultTeam} from '@helpers/api/team';
import {queryAllChannels, queryAllChannelsForTeam} from '@queries/servers/channel';
import {prepareModels, truncateCrtRelatedTables} from '@queries/servers/entry';
import {getHasCRTChanged} from '@queries/servers/preference';
import {getConfig} from '@queries/servers/system';
import {getConfig, getIsDataRetentionEnabled} from '@queries/servers/system';
import {filterAndTransformRoles, getMemberChannelsFromGQLQuery, getMemberTeamsFromGQLQuery, gqlToClientChannelMembership, gqlToClientPreference, gqlToClientSidebarCategory, gqlToClientTeamMembership, gqlToClientUser} from '@utils/graphql';
import {logDebug} from '@utils/log';
import {processIsCRTEnabled} from '@utils/thread';
@@ -265,6 +266,12 @@ export const entry = async (serverUrl: string, teamId?: string, channelId?: stri
result = entryRest(serverUrl, teamId, channelId, since);
}
// Fetch data retention policies
const isDataRetentionEnabled = await getIsDataRetentionEnabled(database);
if (isDataRetentionEnabled) {
fetchDataRetentionPolicy(serverUrl);
}
return result;
};

View File

@@ -436,7 +436,7 @@ export async function fetchPostsBefore(serverUrl: string, channelId: string, pos
await operator.batchRecords(models);
} catch (error) {
logError('FETCH AUTHORS ERROR', error);
logError('FETCH POSTS BEFORE ERROR', error);
}
}
@@ -444,7 +444,7 @@ export async function fetchPostsBefore(serverUrl: string, channelId: string, pos
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
if (activeServerUrl === serverUrl) {
DeviceEventEmitter.emit(Events.LOADING_CHANNEL_POSTS, true);
DeviceEventEmitter.emit(Events.LOADING_CHANNEL_POSTS, false);
}
return {error};
}
@@ -544,9 +544,15 @@ export const fetchPostAuthors = async (serverUrl: string, posts: Post[], fetchOn
}
if (promises.length) {
const result = await Promise.all(promises);
const authors = result.flat();
const authorsResult = await Promise.allSettled(promises);
const result = authorsResult.reduce<UserProfile[][]>((acc, item) => {
if (item.status === 'fulfilled') {
acc.push(item.value);
}
return acc;
}, []);
const authors = result.flat();
if (!fetchOnly && authors.length) {
await operator.handleUsers({
users: authors,

View File

@@ -178,3 +178,19 @@ export const setDirectChannelVisible = async (serverUrl: string, channelId: stri
return {error};
}
};
export const savePreferredSkinTone = async (serverUrl: string, skinCode: string) => {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const userId = await getCurrentUserId(database);
const pref: PreferenceType = {
user_id: userId,
category: Preferences.CATEGORY_EMOJI,
name: Preferences.EMOJI_SKINTONE,
value: skinCode,
};
return savePreference(serverUrl, [pref]);
} catch (error) {
return {error};
}
};

View File

@@ -21,7 +21,6 @@ import {scheduleExpiredNotification} from '@utils/notification';
import {getCSRFFromCookie} from '@utils/security';
import {loginEntry} from './entry';
import {fetchDataRetentionPolicy} from './systems';
import type ClientError from '@client/rest/error';
import type {LoginArgs} from '@typings/database/database';
@@ -42,11 +41,6 @@ export const completeLogin = async (serverUrl: string) => {
return null;
}
// Data retention
if (config?.DataRetentionEnableMessageDeletion === 'true' && license?.IsLicensed === 'true' && license?.DataRetention === 'true') {
fetchDataRetentionPolicy(serverUrl);
}
await DatabaseManager.setActiveServerDatabase(serverUrl);
const systems: IdValue[] = [];

View File

@@ -1,12 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {storeConfigAndLicense} from '@actions/local/systems';
import {storeConfigAndLicense, storeDataRetentionPolicies} from '@actions/local/systems';
import {forceLogoutIfNecessary} from '@actions/remote/session';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import {logError} from '@utils/log';
import {getCurrentUserId} from '@queries/servers/system';
import type ClientError from '@client/rest/error';
@@ -16,7 +15,47 @@ export type ConfigAndLicenseRequest = {
error?: unknown;
}
export const fetchDataRetentionPolicy = async (serverUrl: string) => {
export type DataRetentionPoliciesRequest = {
globalPolicy?: GlobalDataRetentionPolicy;
teamPolicies?: TeamDataRetentionPolicy[];
channelPolicies?: ChannelDataRetentionPolicy[];
error?: unknown;
}
export const fetchDataRetentionPolicy = async (serverUrl: string, fetchOnly = false): Promise<DataRetentionPoliciesRequest> => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
const {data: globalPolicy, error: globalPolicyError} = await fetchGlobalDataRetentionPolicy(serverUrl);
const {data: teamPolicies, error: teamPoliciesError} = await fetchAllGranularDataRetentionPolicies(serverUrl);
const {data: channelPolicies, error: channelPoliciesError} = await fetchAllGranularDataRetentionPolicies(serverUrl, true);
const hasError = globalPolicyError || teamPoliciesError || channelPoliciesError;
if (hasError) {
return hasError;
}
const data = {
globalPolicy,
teamPolicies: teamPolicies as TeamDataRetentionPolicy[],
channelPolicies: channelPolicies as ChannelDataRetentionPolicy[],
};
if (!fetchOnly) {
await storeDataRetentionPolicies(serverUrl, data);
}
return data;
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientError);
return {error};
}
};
export const fetchGlobalDataRetentionPolicy = async (serverUrl: string): Promise<{data?: GlobalDataRetentionPolicy; error?: unknown}> => {
let client;
try {
client = NetworkManager.getClient(serverUrl);
@@ -24,28 +63,47 @@ export const fetchDataRetentionPolicy = async (serverUrl: string) => {
return {error};
}
let data = {};
try {
data = await client.getDataRetentionPolicy();
const data = await client.getGlobalDataRetentionPolicy();
return {data};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientError);
return {error};
}
};
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (operator) {
const systems: IdValue[] = [{
id: SYSTEM_IDENTIFIERS.DATA_RETENTION_POLICIES,
value: JSON.stringify(data),
}];
operator.handleSystem({systems, prepareRecordsOnly: false}).
catch((error) => {
logError('An error occurred while saving data retention policies', error);
});
export const fetchAllGranularDataRetentionPolicies = async (
serverUrl: string,
isChannel = false,
page = 0,
policies: Array<TeamDataRetentionPolicy | ChannelDataRetentionPolicy> = [],
): Promise<{data?: Array<TeamDataRetentionPolicy | ChannelDataRetentionPolicy>; error?: unknown}> => {
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
return data;
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const {database} = operator;
const currentUserId = await getCurrentUserId(database);
let data;
if (isChannel) {
data = await client.getChannelDataRetentionPolicies(currentUserId, page);
} else {
data = await client.getTeamDataRetentionPolicies(currentUserId, page);
}
policies.push(...data.policies);
if (policies.length < data.total_count) {
await fetchAllGranularDataRetentionPolicies(serverUrl, isChannel, page + 1, policies);
}
return {data: policies};
};
export const fetchConfigAndLicense = async (serverUrl: string, fetchOnly = false): Promise<ConfigAndLicenseRequest> => {

View File

@@ -128,7 +128,7 @@ export async function handleChannelViewedEvent(serverUrl: string, msg: any) {
const currentChannelId = await getCurrentChannelId(database);
if (activeServerUrl !== serverUrl || (currentChannelId !== channelId && !EphemeralStore.isSwitchingToChannel(channelId))) {
await markChannelAsViewed(serverUrl, channelId, false);
await markChannelAsViewed(serverUrl, channelId);
}
} catch {
// do nothing

View File

@@ -444,7 +444,7 @@ async function fetchPostDataIfNeeded(serverUrl: string) {
await fetchPostsForChannel(serverUrl, currentChannelId);
markChannelAsRead(serverUrl, currentChannelId);
if (!EphemeralStore.wasNotificationTapped()) {
markChannelAsViewed(serverUrl, currentChannelId);
markChannelAsViewed(serverUrl, currentChannelId, true);
}
EphemeralStore.setNotificationTapped(false);
}

View File

@@ -143,7 +143,7 @@ export async function handleNewPostEvent(serverUrl: string, msg: WebSocketMessag
markChannelAsRead(serverUrl, post.channel_id);
} else if (markAsViewed) {
preparedMyChannelHack(myChannel);
const {member: viewedAt} = await markChannelAsViewed(serverUrl, post.channel_id, true);
const {member: viewedAt} = await markChannelAsViewed(serverUrl, post.channel_id, false, true);
if (viewedAt) {
models.push(viewedAt);
}
@@ -164,8 +164,13 @@ export async function handleNewPostEvent(serverUrl: string, msg: WebSocketMessag
}
}
let actionType: string = ActionType.POSTS.RECEIVED_NEW;
if (isCRTEnabled && post.root_id) {
actionType = ActionType.POSTS.RECEIVED_IN_THREAD;
}
const postModels = await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_NEW,
actionType,
order: [post.id],
posts: [post],
prepareRecordsOnly: true,
@@ -203,8 +208,14 @@ export async function handlePostEdited(serverUrl: string, msg: WebSocketMessage)
models.push(...authorsModels);
}
let actionType: string = ActionType.POSTS.RECEIVED_NEW;
const isCRTEnabled = await getIsCRTEnabled(operator.database);
if (isCRTEnabled && post.root_id) {
actionType = ActionType.POSTS.RECEIVED_IN_THREAD;
}
const postModels = await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_NEW,
actionType,
order: [post.id],
posts: [post],
prepareRecordsOnly: true,

View File

@@ -2,12 +2,22 @@
// 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 EphemeralStore from '@store/ephemeral_store';
export async function handleThreadUpdatedEvent(serverUrl: string, msg: WebSocketMessage): Promise<void> {
try {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return;
}
const thread: Thread = JSON.parse(msg.data.thread);
const teamId = msg.broadcast.team_id;
let teamId = msg.broadcast.team_id;
if (!teamId) {
teamId = await getCurrentTeamId(database);
}
// Mark it as following
thread.is_following = true;

View File

@@ -177,10 +177,14 @@ export default class ClientBase {
return `${this.getEmojisRoute()}/${emojiId}`;
}
getDataRetentionRoute() {
getGlobalDataRetentionRoute() {
return `${this.urlVersion}/data_retention`;
}
getGranularDataRetentionRoute(userId: string) {
return `${this.getUserRoute(userId)}/data_retention`;
}
getRolesRoute() {
return `${this.urlVersion}/roles`;
}

View File

@@ -3,8 +3,14 @@
import {buildQueryString} from '@utils/helpers';
import {PER_PAGE_DEFAULT} from './constants';
import ClientError from './error';
type PoliciesResponse<T> = {
policies: T[];
total_count: number;
}
export interface ClientGeneralMix {
getOpenGraphMetadata: (url: string) => Promise<any>;
ping: (deviceId?: string, timeoutInterval?: number) => Promise<any>;
@@ -12,7 +18,9 @@ export interface ClientGeneralMix {
getClientConfigOld: () => Promise<ClientConfig>;
getClientLicenseOld: () => Promise<ClientLicense>;
getTimezones: () => Promise<string[]>;
getDataRetentionPolicy: () => Promise<any>;
getGlobalDataRetentionPolicy: () => Promise<GlobalDataRetentionPolicy>;
getTeamDataRetentionPolicies: (userId: string, page?: number, perPage?: number) => Promise<PoliciesResponse<TeamDataRetentionPolicy>>;
getChannelDataRetentionPolicies: (userId: string, page?: number, perPage?: number) => Promise<PoliciesResponse<ChannelDataRetentionPolicy>>;
getRolesByNames: (rolesNames: string[]) => Promise<Role[]>;
getRedirectLocation: (urlParam: string) => Promise<Record<string, string>>;
}
@@ -74,9 +82,23 @@ const ClientGeneral = (superclass: any) => class extends superclass {
);
};
getDataRetentionPolicy = () => {
getGlobalDataRetentionPolicy = () => {
return this.doFetch(
`${this.getDataRetentionRoute()}/policy`,
`${this.getGlobalDataRetentionRoute()}/policy`,
{method: 'get'},
);
};
getTeamDataRetentionPolicies = (userId: string, page = 0, perPage = PER_PAGE_DEFAULT) => {
return this.doFetch(
`${this.getGranularDataRetentionRoute(userId)}/team_policies${buildQueryString({page, per_page: perPage})}`,
{method: 'get'},
);
};
getChannelDataRetentionPolicies = (userId: string, page = 0, perPage = PER_PAGE_DEFAULT) => {
return this.doFetch(
`${this.getGranularDataRetentionRoute(userId)}/channel_policies${buildQueryString({page, per_page: perPage})}`,
{method: 'get'},
);
};

View File

@@ -33,6 +33,7 @@ export default class WebSocketClient {
private firstConnectCallback?: () => void;
private missedEventsCallback?: () => void;
private reconnectCallback?: () => void;
private reliableReconnectCallback?: () => void;
private errorCallback?: Function;
private closeCallback?: (connectFailCount: number, lastDisconnect: number) => void;
private connectingCallback?: () => void;
@@ -148,8 +149,11 @@ export default class WebSocketClient {
logInfo('websocket re-established connection to', this.url);
if (!reliableWebSockets && this.reconnectCallback) {
this.reconnectCallback();
} else if (reliableWebSockets && this.serverSequence && this.missedEventsCallback) {
this.missedEventsCallback();
} else if (reliableWebSockets) {
this.reliableReconnectCallback?.();
if (this.serverSequence && this.missedEventsCallback) {
this.missedEventsCallback();
}
}
} else if (this.firstConnectCallback) {
logInfo('websocket connected to', this.url);
@@ -295,6 +299,10 @@ export default class WebSocketClient {
this.reconnectCallback = callback;
}
public setReliableReconnectCallback(callback: () => void) {
this.reliableReconnectCallback = callback;
}
public setErrorCallback(callback: Function) {
this.errorCallback = callback;
}

View File

@@ -546,7 +546,7 @@ export class ParsedCommand {
this.incomplete += c;
this.i++;
if (escaped) {
//TODO: handle \n, \t, other escaped chars
//TODO: handle \n, \t, other escaped chars https://mattermost.atlassian.net/browse/MM-43476
escaped = false;
}
break;
@@ -735,7 +735,7 @@ export class ParsedCommand {
this.incomplete += c;
this.i++;
if (escaped) {
//TODO: handle \n, \t, other escaped chars
//TODO: handle \n, \t, other escaped chars https://mattermost.atlassian.net/browse/MM-43476
escaped = false;
}
break;
@@ -1079,7 +1079,7 @@ export class AppCommandParser {
}
// Add "Execute Current Command" suggestion
// TODO get full text from SuggestionBox
// TODO get full text from SuggestionBox https://mattermost.atlassian.net/browse/MM-43477
const executableStates: string[] = [
ParseState.EndCommand,
ParseState.CommandSeparator,

View File

@@ -19,7 +19,7 @@ import IntegrationsManager from '@managers/integrations_manager';
import {AppCommandParser} from './app_command_parser/app_command_parser';
import SlashSuggestionItem from './slash_suggestion_item';
// TODO: Remove when all below commands have been implemented
// TODO: Remove when all below commands have been implemented https://mattermost.atlassian.net/browse/MM-43478
const COMMANDS_TO_IMPLEMENT_LATER = ['collapse', 'expand', 'logout'];
const NON_MOBILE_COMMANDS = ['shortcuts', 'search', 'settings'];

View File

@@ -106,6 +106,7 @@ const ConnectionBanner = ({
}
return () => {
clearTimeoutRef(openTimeout);
clearTimeoutRef(closeTimeout);
};
}, []);
@@ -125,7 +126,7 @@ const ConnectionBanner = ({
}
}, [isConnected]);
useEffect(() => {
useDidUpdate(() => {
if (appState === 'active') {
if (!isConnected && !visible) {
if (!openTimeout.current) {
@@ -138,10 +139,11 @@ const ConnectionBanner = ({
}
}
} else {
setVisible(false);
clearTimeoutRef(openTimeout);
clearTimeoutRef(closeTimeout);
}
}, [appState]);
}, [appState === 'active']);
useEffect(() => {
height.value = withTiming(visible ? ANNOUNCEMENT_BAR_HEIGHT : 0, {
@@ -149,12 +151,6 @@ const ConnectionBanner = ({
});
}, [visible]);
useEffect(() => {
return () => {
clearTimeoutRef(closeTimeout);
};
});
const bannerStyle = useAnimatedStyle(() => ({
height: height.value,
}));

View File

@@ -1,76 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import {KeyboardTrackingView} from 'react-native-keyboard-tracking-view';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import SectionIcon from './icon';
export const SCROLLVIEW_NATIVE_ID = 'emojiSelector';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
bottom: 10,
height: 35,
position: 'absolute',
width: '100%',
},
background: {
backgroundColor: theme.centerChannelBg,
},
pane: {
flexDirection: 'row',
borderRadius: 10,
paddingHorizontal: 10,
width: '100%',
borderColor: changeOpacity(theme.centerChannelColor, 0.3),
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
borderWidth: 1,
justifyContent: 'space-between',
},
}));
export type SectionIconType = {
key: string;
icon: string;
}
type Props = {
currentIndex: number;
sections: SectionIconType[];
scrollToIndex: (index: number) => void;
}
const EmojiSectionBar = ({currentIndex, sections, scrollToIndex}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
return (
<KeyboardTrackingView
scrollViewNativeID={SCROLLVIEW_NATIVE_ID}
normalList={true}
style={styles.container}
testID='emoji_picker.emoji_sections.section_bar'
>
<View style={styles.background}>
<View style={styles.pane}>
{sections.map((section, index) => (
<SectionIcon
currentIndex={currentIndex}
key={section.key}
icon={section.icon}
index={index}
scrollToIndex={scrollToIndex}
theme={theme}
/>
))}
</View>
</View>
</KeyboardTrackingView>
);
};
export default EmojiSectionBar;

View File

@@ -1,35 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {StyleProp, ViewStyle} from 'react-native';
import Emoji from '@components/emoji';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {preventDoubleTap} from '@utils/tap';
type Props = {
name: string;
onEmojiPress: (emoji: string) => void;
size?: number;
style: StyleProp<ViewStyle>;
}
const TouchableEmoji = ({name, onEmojiPress, size = 30, style}: Props) => {
const onPress = useCallback(preventDoubleTap(() => onEmojiPress(name)), []);
return (
<TouchableWithFeedback
onPress={onPress}
style={style}
type={'opacity'}
>
<Emoji
emojiName={name}
size={size}
/>
</TouchableWithFeedback>
);
};
export default React.memo(TouchableEmoji);

View File

@@ -85,7 +85,7 @@ const MarkdownCodeBlock = ({language = '', content, textStyle}: MarkdownCodeBloc
const screen = Screens.CODE;
const passProps = {
code: content,
language,
language: getHighlightLanguageFromNameOrAlias(language),
textStyle,
};

View File

@@ -12,7 +12,7 @@ import {MAX_ALLOWED_REACTIONS} from '@constants/emoji';
import {useServerUrl} from '@context/server';
import {useIsTablet} from '@hooks/device';
import useDidUpdate from '@hooks/did_update';
import {bottomSheetModalOptions, showModal, showModalOverCurrentContext} from '@screens/navigation';
import {bottomSheetModalOptions, openAsBottomSheet, showModal, showModalOverCurrentContext} from '@screens/navigation';
import {getEmojiFirstAlias} from '@utils/emoji/helpers';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@@ -61,7 +61,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
});
const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled, location, postId, reactions, theme}: ReactionsProps) => {
const intl = useIntl();
const {formatMessage} = useIntl();
const serverUrl = useServerUrl();
const isTablet = useIsTablet();
const pressed = useRef(false);
@@ -112,16 +112,14 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
};
const handleAddReaction = useCallback(preventDoubleTap(() => {
const title = intl.formatMessage({id: 'mobile.post_info.add_reaction', defaultMessage: 'Add Reaction'});
const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
const passProps = {
closeButton,
onEmojiPress: handleAddReactionToPost,
};
showModal(Screens.EMOJI_PICKER, title, passProps);
}), [intl, theme]);
openAsBottomSheet({
closeButtonId: 'close-add-reaction',
screen: Screens.EMOJI_PICKER,
theme,
title: formatMessage({id: 'mobile.post_info.add_reaction', defaultMessage: 'Add Reaction'}),
props: {onEmojiPress: handleAddReactionToPost},
});
}), [formatMessage, theme]);
const handleReactionPress = useCallback(async (emoji: string, remove: boolean) => {
pressed.current = true;
@@ -143,7 +141,7 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
};
Keyboard.dismiss();
const title = isTablet ? intl.formatMessage({id: 'post.reactions.title', defaultMessage: 'Reactions'}) : '';
const title = isTablet ? formatMessage({id: 'post.reactions.title', defaultMessage: 'Reactions'}) : '';
if (!pressed.current) {
if (isTablet) {
@@ -152,7 +150,7 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
showModalOverCurrentContext(screen, passProps, bottomSheetModalOptions(theme));
}
}
}, [intl, isTablet, location, postId, theme]);
}, [formatMessage, isTablet, location, postId, theme]);
let addMoreReactions = null;
const {reactionsByName, highlightedReactions} = buildReactionsMap();

View File

@@ -17,7 +17,7 @@ import * as Screens from '@constants/screens';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {bottomSheetModalOptions, showModal, showModalOverCurrentContext} from '@screens/navigation';
import {openAsBottomSheet} from '@screens/navigation';
import {hasJumboEmojiOnly} from '@utils/emoji/helpers';
import {fromAutoResponder, isFromWebhook, isPostFailed, isPostPendingOrFailed, isSystemMessage} from '@utils/post';
import {preventDoubleTap} from '@utils/tap';
@@ -192,11 +192,13 @@ const Post = ({
const passProps = {sourceScreen: location, post, showAddReaction, serverUrl};
const title = isTablet ? intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}) : '';
if (isTablet) {
showModal(Screens.POST_OPTIONS, title, passProps, bottomSheetModalOptions(theme, 'close-post-options'));
} else {
showModalOverCurrentContext(Screens.POST_OPTIONS, passProps, bottomSheetModalOptions(theme));
}
openAsBottomSheet({
closeButtonId: 'close-post-options',
screen: Screens.POST_OPTIONS,
theme,
title,
props: passProps,
});
};
const [, rerender] = useState(false);

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {StyleProp, View, ViewStyle} from 'react-native';
import Emoji from '@components/emoji';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {preventDoubleTap} from '@utils/tap';
import SkinnedEmoji from './skinned_emoji';
type Props = {
category?: string;
name: string;
onEmojiPress: (emoji: string) => void;
size?: number;
style?: StyleProp<ViewStyle>;
}
const CATEGORIES_WITH_SKINS = ['people-body'];
const hitSlop = {top: 10, bottom: 10, left: 10, right: 10};
const TouchableEmoji = ({category, name, onEmojiPress, size = 30, style}: Props) => {
const onPress = useCallback(preventDoubleTap(() => onEmojiPress(name)), []);
let emoji;
if (category && CATEGORIES_WITH_SKINS.includes(category)) {
emoji = (
<SkinnedEmoji
name={name}
size={size}
/>
);
} else {
emoji = (
<Emoji
emojiName={name}
size={size}
/>
);
}
return (
<View
style={style}
>
<TouchableWithFeedback
hitSlop={hitSlop}
onPress={onPress}
style={style}
type={'opacity'}
>
{emoji}
</TouchableWithFeedback>
</View>
);
};
export default React.memo(TouchableEmoji);

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {useEmojiSkinTone} from '@app/hooks/emoji_category_bar';
import Emoji from '@components/emoji';
import {skinCodes} from '@utils/emoji';
import {isValidNamedEmoji} from '@utils/emoji/helpers';
type Props = {
name: string;
size?: number;
}
const SkinnedEmoji = ({name, size = 30}: Props) => {
const skinTone = useEmojiSkinTone();
const emojiName = useMemo(() => {
const skinnedEmoji = `${name}_${skinCodes[skinTone]}`;
if (skinTone === 'default' || !isValidNamedEmoji(skinnedEmoji, [])) {
return name;
}
return skinnedEmoji;
}, [name, skinTone]);
return (
<Emoji
emojiName={emojiName}
size={size}
/>
);
};
export default React.memo(SkinnedEmoji);

View File

@@ -127,7 +127,7 @@ const UserAvatarsStack = ({breakAt = 3, channelId, location, style: baseContaine
const snapPoints: BottomSheetProps['snapPoints'] = [1, bottomSheetSnapPoint(Math.min(users.length, 5), USER_ROW_HEIGHT, bottom) + TITLE_HEIGHT];
if (users.length > 5) {
snapPoints.push('90%');
snapPoints.push('80%');
}
bottomSheet({

View File

@@ -52,13 +52,14 @@ export const MIGRATION_EVENTS = keyMirror({
});
export const SYSTEM_IDENTIFIERS = {
CONFIG: 'config',
CURRENT_CHANNEL_ID: 'currentChannelId',
LAST_UNREAD_CHANNEL_ID: 'lastUnreadChannelId',
CURRENT_TEAM_ID: 'currentTeamId',
CURRENT_USER_ID: 'currentUserId',
DATA_RETENTION_POLICIES: 'dataRetentionPolicies',
EXPANDED_LINKS: 'expandedLinks',
GRANULAR_DATA_RETENTION_POLICIES: 'granularDataRetentionPolicies',
LAST_DATA_RETENTION_RUN: 'lastDataRetentionRun',
GLOBAL_THREADS_TAB: 'globalThreadsTab',
LAST_DISMISSED_BANNER: 'lastDismissedBanner',
LAST_SERVER_VERSION_CHECK: 'LastServerVersionCheck',
@@ -78,8 +79,6 @@ export const GLOBAL_IDENTIFIERS = {
DONT_ASK_FOR_REVIEW: 'dontAskForReview',
FIRST_LAUNCH: 'firstLaunch',
LAST_ASK_FOR_REVIEW: 'lastAskForReview',
MULTI_SERVER_TUTORIAL: 'multiServerTutorial',
PROFILE_LONG_PRESS_TUTORIAL: 'profileLongPressTutorial',
ONBOARDING: 'onboarding',
};

View File

@@ -34,6 +34,7 @@ import ServerErrors from './server_errors';
import SnackBar from './snack_bar';
import Sso from './sso';
import SupportedServer from './supported_server';
import Tutorial from './tutorial';
import View from './view';
import WebsocketEvents from './websocket';
@@ -71,6 +72,7 @@ export {
SnackBar,
Sso,
SupportedServer,
Tutorial,
View,
WebsocketEvents,
};

View File

@@ -142,20 +142,23 @@ export const MODAL_SCREENS_WITHOUT_BACK = new Set<string>([
EDIT_POST,
EDIT_PROFILE,
EDIT_SERVER,
EMOJI_PICKER,
FIND_CHANNELS,
GALLERY,
PERMALINK,
REACTIONS,
]);
export const SCREENS_WITH_TRANSPARENT_BACKGROUND = new Set<string>([
PERMALINK,
REVIEW_APP,
SNACK_BAR,
]);
export const SCREENS_AS_BOTTOM_SHEET = new Set<string>([
BOTTOM_SHEET,
EMOJI_PICKER,
POST_OPTIONS,
THREAD_OPTIONS,
PERMALINK,
REACTIONS,
SNACK_BAR,
USER_PROFILE,
]);

12
app/constants/tutorial.ts Normal file
View File

@@ -0,0 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const MULTI_SERVER = 'multiServerTutorial';
export const PROFILE_LONG_PRESS = 'profileLongPressTutorial';
export const EMOJI_SKIN_SELECTOR = 'emojiSkinSelectorTutorial';
export default {
MULTI_SERVER,
PROFILE_LONG_PRESS,
EMOJI_SKIN_SELECTOR,
};

View File

@@ -8,7 +8,7 @@ import {DeviceEventEmitter, Platform} from 'react-native';
import DeviceInfo from 'react-native-device-info';
import FileSystem from 'react-native-fs';
import {DatabaseType, MIGRATION_EVENTS, MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {DatabaseType, MIGRATION_EVENTS, MM_TABLES} from '@constants/database';
import AppDatabaseMigrations from '@database/migration/app';
import ServerDatabaseMigrations from '@database/migration/server';
import {InfoModel, GlobalModel, ServersModel} from '@database/models/app';
@@ -22,13 +22,12 @@ import AppDataOperator from '@database/operator/app_data_operator';
import ServerDataOperator from '@database/operator/server_data_operator';
import {schema as appSchema} from '@database/schema/app';
import {serverSchema} from '@database/schema/server';
import {beforeUpgrade} from '@helpers/database/upgrade';
import {getActiveServer, getServer, getServerByIdentifier} from '@queries/app/servers';
import {querySystemValue} from '@queries/servers/system';
import {deleteLegacyFileCache} from '@utils/file';
import {emptyFunction} from '@utils/general';
import {logDebug, logError} from '@utils/log';
import {deleteIOSDatabase, getIOSAppGroupDetails, renameIOSDatabase} from '@utils/mattermost_managed';
import {hashCode_DEPRECATED, urlSafeBase64Encode} from '@utils/security';
import {urlSafeBase64Encode} from '@utils/security';
import {removeProtocol} from '@utils/url';
import type {AppDatabase, CreateServerDatabaseArgs, RegisterServerDatabaseArgs, Models, ServerDatabase, ServerDatabases} from '@typings/database/database';
@@ -63,14 +62,17 @@ class DatabaseManager {
*/
public init = async (serverUrls: string[]): Promise<void> => {
await this.createAppDatabase();
const buildNumber = DeviceInfo.getBuildNumber();
const versionNumber = DeviceInfo.getVersion();
await beforeUpgrade.call(this, serverUrls, versionNumber, buildNumber);
for await (const serverUrl of serverUrls) {
await this.initServerDatabase(serverUrl);
}
this.appDatabase?.operator.handleInfo({
info: [{
build_number: DeviceInfo.getBuildNumber(),
build_number: buildNumber,
created_at: Date.now(),
version_number: DeviceInfo.getVersion(),
version_number: versionNumber,
}],
prepareRecordsOnly: false,
});
@@ -129,12 +131,6 @@ class DatabaseManager {
if (serverUrl) {
try {
const databaseName = urlSafeBase64Encode(serverUrl);
const oldDatabaseName = hashCode_DEPRECATED(serverUrl);
// Remove any legacy database we may already have.
await this.renameDatabase(oldDatabaseName, databaseName);
deleteLegacyFileCache(serverUrl);
const databaseFilePath = this.getDatabaseFilePath(databaseName);
const migrations = ServerDatabaseMigrations;
const modelClasses = this.serverModels;
@@ -177,38 +173,13 @@ class DatabaseManager {
* @returns {Promise<void>}
*/
private initServerDatabase = async (serverUrl: string): Promise<void> => {
const serverDatabase = await this.createServerDatabase({
await this.createServerDatabase({
config: {
dbName: serverUrl,
dbType: DatabaseType.SERVER,
serverUrl,
},
});
// Migration for config
if (serverDatabase) {
const {database, operator} = serverDatabase;
const oldConfigList = await querySystemValue(database, SYSTEM_IDENTIFIERS.CONFIG).fetch();
if (oldConfigList.length) {
const oldConfigModel = oldConfigList[0];
const oldConfig = oldConfigModel.value as ClientConfig;
const configs = [];
let k: keyof ClientConfig;
for (k in oldConfig) {
// Check to silence eslint (guard-for-in)
if (Object.prototype.hasOwnProperty.call(oldConfig, k)) {
configs.push({
id: k,
value: oldConfig[k],
});
}
}
const models = await operator.handleConfigs({configs, configsToDelete: [], prepareRecordsOnly: true});
operator.batchRecords([...models, oldConfigModel.prepareDestroyPermanently()]);
}
}
};
/**
@@ -428,7 +399,7 @@ class DatabaseManager {
*/
private deleteServerDatabaseFiles = async (serverUrl: string): Promise<void> => {
const databaseName = urlSafeBase64Encode(serverUrl);
this.deleteServerDatabaseFilesByName(databaseName);
return this.deleteServerDatabaseFilesByName(databaseName);
};
/**
@@ -439,7 +410,7 @@ class DatabaseManager {
private deleteServerDatabaseFilesByName = async (databaseName: string): Promise<void> => {
if (Platform.OS === 'ios') {
// On iOS, we'll delete the *.db file under the shared app-group/databases folder
deleteIOSDatabase({databaseName});
await deleteIOSDatabase({databaseName});
return;
}
@@ -449,14 +420,15 @@ class DatabaseManager {
const databaseShm = `${androidFilesDir}${databaseName}.db-shm`;
const databaseWal = `${androidFilesDir}${databaseName}.db-wal`;
FileSystem.unlink(databaseFile).catch(emptyFunction);
FileSystem.unlink(databaseShm).catch(emptyFunction);
FileSystem.unlink(databaseWal).catch(emptyFunction);
await FileSystem.unlink(databaseFile).catch(emptyFunction);
await FileSystem.unlink(databaseShm).catch(emptyFunction);
await FileSystem.unlink(databaseWal).catch(emptyFunction);
};
/**
* deleteServerDatabaseFilesByName: Removes the *.db file from the App-Group directory for iOS or the files directory for Android, given the database name
* renameDatabase: Renames the *.db file from the App-Group directory for iOS or the files directory for Android
* @param {string} databaseName
* @param {string} newDBName
* @returns {Promise<void>}
*/
private renameDatabase = async (databaseName: string, newDBName: string): Promise<void> => {

View File

@@ -4,105 +4,6 @@
// NOTE : To implement migration, please follow this document
// https://nozbe.github.io/WatermelonDB/Advanced/Migrations.html
import {schemaMigrations, addColumns, createTable} from '@nozbe/watermelondb/Schema/migrations';
import {schemaMigrations} from '@nozbe/watermelondb/Schema/migrations';
import {MM_TABLES} from '@constants/database';
import {tableSchemaSpec as configSpec} from '@database/schema/server/table_schemas/config';
import {tableSchemaSpec as teamThreadsSyncSpec} from '@database/schema/server/table_schemas/team_threads_sync';
import {tableSchemaSpec as threadSpec} from '@database/schema/server/table_schemas/thread';
import {tableSchemaSpec as threadInTeamSpec} from '@database/schema/server/table_schemas/thread_in_team';
import {tableSchemaSpec as threadParticipantSpec} from '@database/schema/server/table_schemas/thread_participant';
const {SERVER: {
GROUP,
MY_CHANNEL,
TEAM,
THREAD,
THREAD_PARTICIPANT,
THREADS_IN_TEAM,
USER,
}} = MM_TABLES;
export default schemaMigrations({migrations: [
{
toVersion: 7,
steps: [
// Along with adding the new table - TeamThreadsSync,
// We need to clear the data in thread related tables (DROP & CREATE) to fetch the fresh data from the server
createTable({
...teamThreadsSyncSpec,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
unsafeSql: (baseSql) => {
return `
${baseSql}
DROP TABLE ${THREAD};
DROP TABLE ${THREADS_IN_TEAM};
DROP TABLE ${THREAD_PARTICIPANT};
`;
},
}),
createTable(threadSpec),
createTable(threadInTeamSpec),
createTable(threadParticipantSpec),
],
},
{
toVersion: 6,
steps: [
addColumns({
table: USER,
columns: [
{name: 'terms_of_service_id', type: 'string'},
{name: 'terms_of_service_create_at', type: 'number'},
],
}),
],
},
{
toVersion: 5,
steps: [
createTable(configSpec),
],
},
{
toVersion: 4,
steps: [
addColumns({
table: TEAM,
columns: [
{name: 'invite_id', type: 'string'},
],
}),
],
},
{
toVersion: 3,
steps: [
addColumns({
table: GROUP,
columns: [
{name: 'member_count', type: 'number'},
],
}),
],
},
{
toVersion: 2,
steps: [
addColumns({
table: MY_CHANNEL,
columns: [
{name: 'last_fetched_at', type: 'number', isIndexed: true},
],
}),
addColumns({
table: THREAD,
columns: [
{name: 'last_fetched_at', type: 'number', isIndexed: true},
],
}),
],
},
]});
export default schemaMigrations({migrations: []});

View File

@@ -11,8 +11,6 @@ import type GlobalModelInterface from '@typings/database/models/app/global';
const {GLOBAL} = MM_TABLES.APP;
// TODO : add TS definitions to sanitizer function signature.
/**
* The Global model will act as a dictionary of name-value pairs. The value field can be a JSON object or any other
* data type. It will hold information that applies to the whole app ( e.g. sidebar settings for tablets)

View File

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

View File

@@ -45,7 +45,7 @@ const {
describe('*** Test schema for SERVER database ***', () => {
it('=> The SERVER SCHEMA should strictly match', () => {
expect(serverSchema).toEqual({
version: 7,
version: 1,
unsafeSql: undefined,
tables: {
[CATEGORY]: {

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getLastInstalledVersion} from '@queries/app/info';
import {logError, logInfo} from '@utils/log';
import type {DatabaseManager} from '@typings/database/manager';
import type InfoModel from '@typings/database/models/app/info';
export async function beforeUpgrade(serverUrls: string[], versionNumber: string, buildNumber: string) {
const info = await getLastInstalledVersion();
const manager: DatabaseManager | undefined = this.serverDatabases ? this : undefined;
if (manager && serverUrls.length && info && (versionNumber !== info.versionNumber || buildNumber !== info.buildNumber)) {
await beforeUpgradeTo450(manager, serverUrls, info);
}
}
async function beforeUpgradeTo450(manager: DatabaseManager, serverUrls: string[], info: InfoModel) {
try {
const buildNumber = parseInt(info.buildNumber, 10);
if (info.versionNumber === '2.0.0' && buildNumber < 450) {
for await (const serverUrl of serverUrls) {
logInfo('Remove database before upgrading for', serverUrl);
await manager.deleteServerDatabaseFiles(serverUrl);
}
}
} catch (e) {
logError('Error running the upgrade before build 450', e);
}
}

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useState} from 'react';
import {BehaviorSubject} from 'rxjs';
export type EmojiCategoryBarIcon = {
key: string;
icon: string;
}
type EmojiCategoryBar = {
currentIndex: number;
selectedIndex?: number;
icons?: EmojiCategoryBarIcon[];
skinTone: string;
};
const defaultState: EmojiCategoryBar = {
icons: undefined,
currentIndex: 0,
selectedIndex: undefined,
skinTone: 'default',
};
const subject: BehaviorSubject<EmojiCategoryBar> = new BehaviorSubject(defaultState);
const getEmojiCategoryBarState = () => {
return subject.value;
};
export const selectEmojiCategoryBarSection = (index?: number) => {
const prevState = getEmojiCategoryBarState();
subject.next({
...prevState,
selectedIndex: index,
});
};
export const setEmojiCategoryBarSection = (index: number) => {
const prevState = getEmojiCategoryBarState();
subject.next({
...prevState,
currentIndex: index,
});
};
export const setEmojiCategoryBarIcons = (icons?: EmojiCategoryBarIcon[]) => {
const prevState = getEmojiCategoryBarState();
subject.next({
...prevState,
icons,
});
};
export const setEmojiSkinTone = (skinTone: string) => {
const prevState = getEmojiCategoryBarState();
subject.next({
...prevState,
skinTone,
});
};
export const useEmojiCategoryBar = () => {
const [state, setState] = useState(defaultState);
useEffect(() => {
const sub = subject.subscribe(setState);
return () => {
sub.unsubscribe();
subject.next(defaultState);
};
}, []);
return state;
};
export const useEmojiSkinTone = () => {
const [tone, setTone] = useState(defaultState.skinTone);
useEffect(() => {
const sub = subject.subscribe((state) => {
setTone(state.skinTone);
});
return () => {
sub.unsubscribe();
};
}, []);
return tone;
};

View File

@@ -47,11 +47,11 @@ export async function initialize() {
export async function start() {
await initialize();
await WebsocketManager.init(serverCredentials);
PushNotifications.init(serverCredentials.length > 0);
registerNavigationListeners();
registerScreens();
initialLaunch();
await initialLaunch();
WebsocketManager.init(serverCredentials);
}

View File

@@ -94,7 +94,7 @@ export const getServerCredentials = async (serverUrl: string): Promise<ServerCre
if (credentials) {
// TODO: Pre-Gekidou we were concatenating the deviceToken and the userId in
// credentials.username so we need to check the length of credentials.username.split(',').
// This check should be removed at some point.
// This check should be removed at some point. https://mattermost.atlassian.net/browse/MM-43483
const parts = credentials.username.split(',');
const userId = parts[parts.length - 1];

View File

@@ -27,7 +27,7 @@ const initialNotificationTypes = [PushNotification.NOTIFICATION_TYPE.MESSAGE, Pu
export const initialLaunch = async () => {
const deepLinkUrl = await Linking.getInitialURL();
if (deepLinkUrl) {
launchAppFromDeepLink(deepLinkUrl, true);
await launchAppFromDeepLink(deepLinkUrl, true);
return;
}
@@ -43,21 +43,21 @@ 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) {
launchAppFromNotification(convertToNotificationData(notification!), true);
await launchAppFromNotification(convertToNotificationData(notification!), true);
return;
}
launchApp({launchType: Launch.Normal, coldStart: true});
await launchApp({launchType: Launch.Normal, coldStart: true});
};
const launchAppFromDeepLink = (deepLinkUrl: string, coldStart = false) => {
const launchAppFromDeepLink = async (deepLinkUrl: string, coldStart = false) => {
const props = getLaunchPropsFromDeepLink(deepLinkUrl, coldStart);
launchApp(props);
return launchApp(props);
};
const launchAppFromNotification = async (notification: NotificationWithData, coldStart = false) => {
const props = await getLaunchPropsFromNotification(notification, coldStart);
launchApp(props);
return launchApp(props);
};
/**

View File

@@ -96,7 +96,7 @@ class PushNotifications {
markThreadAsRead(serverUrl, payload.team_id, payload.post_id);
}
} else {
markChannelAsViewed(serverUrl, payload.channel_id, false);
markChannelAsViewed(serverUrl, payload.channel_id);
}
}
}

View File

@@ -6,11 +6,7 @@ import DeviceInfo from 'react-native-device-info';
import LocalConfig from '@assets/config.json';
import {Device} from '@constants';
const isSystemAdmin = (roles: string) => {
// TODO: Replace this function with an utility function based on previous code
return roles === 'system_admin';
};
import {isSystemAdmin} from '@utils/user';
const clientMap: Record<string, Analytics> = {};
@@ -28,7 +24,9 @@ export class Analytics {
};
async init(config: ClientConfig) {
this.analytics = require('@rudderstack/rudder-sdk-react-native').default;
if (LocalConfig.RudderApiKey) {
this.analytics = require('@rudderstack/rudder-sdk-react-native').default;
}
if (this.analytics) {
const {height, width} = Dimensions.get('window');
@@ -123,10 +121,18 @@ export class Analytics {
}
trackAPI(event: string, props?: any) {
if (!this.analytics) {
return;
}
this.trackEvent('api', event, props);
}
trackCommand(event: string, command: string, errorMessage?: string) {
if (!this.analytics) {
return;
}
const sanitizedCommand = this.sanitizeCommand(command);
let props: any;
if (errorMessage) {
@@ -139,6 +145,9 @@ export class Analytics {
}
trackAction(event: string, props?: any) {
if (!this.analytics) {
return;
}
this.trackEvent('action', event, props);
}

View File

@@ -78,8 +78,9 @@ class SessionManager {
};
private clearCookiesForServer = async (serverUrl: string) => {
this.clearCookies(serverUrl, false);
if (Platform.OS === 'ios') {
this.clearCookies(serverUrl, false);
// Also delete any cookies that were set by react-native-webview
this.clearCookies(serverUrl, true);
} else if (Platform.OS === 'android') {

View File

@@ -30,12 +30,12 @@ class WebsocketManager {
private connectionTimerIDs: Record<string, DebouncedFunc<() => void>> = {};
private isBackgroundTimerRunning = false;
private netConnected = false;
private previousAppState: AppStateStatus;
private previousActiveState: boolean;
private statusUpdatesIntervalIDs: Record<string, NodeJS.Timer> = {};
private backgroundIntervalId: number | undefined;
constructor() {
this.previousAppState = AppState.currentState;
this.previousActiveState = AppState.currentState === 'active';
}
public init = async (serverCredentials: ServerCredential[]) => {
@@ -81,6 +81,7 @@ class WebsocketManager {
//client.setMissedEventsCallback(() => {}) Nothing to do on missedEvents callback
client.setReconnectCallback(() => this.onReconnect(serverUrl));
client.setReliableReconnectCallback(() => this.onReliableReconnect(serverUrl));
client.setCloseCallback((connectFailCount: number, lastDisconnect: number) => this.onWebsocketClose(serverUrl, connectFailCount, lastDisconnect));
if (this.netConnected && ['unknown', 'active'].includes(AppState.currentState)) {
@@ -153,23 +154,27 @@ class WebsocketManager {
private onFirstConnect = (serverUrl: string) => {
this.startPeriodicStatusUpdates(serverUrl);
handleFirstConnect(serverUrl);
this.getConnectedSubject(serverUrl).next('connected');
handleFirstConnect(serverUrl);
};
private onReconnect = async (serverUrl: string) => {
this.startPeriodicStatusUpdates(serverUrl);
this.getConnectedSubject(serverUrl).next('connected');
await handleReconnect(serverUrl);
};
private onReliableReconnect = async (serverUrl: string) => {
this.getConnectedSubject(serverUrl).next('connected');
};
private onWebsocketClose = async (serverUrl: string, connectFailCount: number, lastDisconnect: number) => {
this.getConnectedSubject(serverUrl).next('not_connected');
if (connectFailCount <= 1) { // First fail
await setCurrentUserStatusOffline(serverUrl);
await handleClose(serverUrl, lastDisconnect);
this.stopPeriodicStatusUpdates(serverUrl);
this.getConnectedSubject(serverUrl).next('not_connected');
}
};
@@ -205,14 +210,15 @@ class WebsocketManager {
}
private onAppStateChange = async (appState: AppStateStatus) => {
if (appState === this.previousAppState) {
const isActive = appState === 'active';
if (isActive === this.previousActiveState) {
return;
}
const isMain = isMainActivity();
this.cancelAllConnections();
if (appState !== 'active' && !this.isBackgroundTimerRunning) {
if (!isActive && !this.isBackgroundTimerRunning) {
this.isBackgroundTimerRunning = true;
this.cancelAllConnections();
this.backgroundIntervalId = BackgroundTimer.setInterval(() => {
@@ -221,22 +227,22 @@ class WebsocketManager {
this.isBackgroundTimerRunning = false;
}, WAIT_TO_CLOSE);
this.previousAppState = appState;
this.previousActiveState = isActive;
return;
}
if (appState === 'active' && this.netConnected && isMain) { // Reopen the websockets only if there is connection
if (isActive && this.netConnected && isMain) { // Reopen the websockets only if there is connection
if (this.backgroundIntervalId) {
BackgroundTimer.clearInterval(this.backgroundIntervalId);
}
this.isBackgroundTimerRunning = false;
this.openAll();
this.previousAppState = appState;
this.previousActiveState = isActive;
return;
}
if (isMain) {
this.previousAppState = appState;
this.previousActiveState = isActive;
}
};
@@ -248,7 +254,7 @@ class WebsocketManager {
this.netConnected = newState;
if (this.netConnected && this.previousAppState === 'active') { // Reopen the websockets only if the app is active
if (this.netConnected && this.previousActiveState) { // Reopen the websockets only if the app is active
this.openAll();
return;
}

View File

@@ -21,7 +21,7 @@ const enhanced = withObservables([], () => {
switchMap((url) => of$(DatabaseManager.serverDatabases[url]?.database)),
);
// TODO: to be optimized
// TODO: to be optimized https://mattermost.atlassian.net/browse/MM-49338
const participantsDict = combineLatest([database, currentCall]).pipe(
switchMap(([db, call]) => (db && call ? queryUsersById(db, Object.keys(call.participants)).observeWithColumns(['nickname', 'username', 'first_name', 'last_name', 'last_picture_update']) : of$([])).pipe(
// eslint-disable-next-line max-nested-callbacks

View File

@@ -31,17 +31,6 @@ export const queryGlobalValue = (key: string) => {
}
};
export const observeMultiServerTutorial = () => {
const query = queryGlobalValue(GLOBAL_IDENTIFIERS.MULTI_SERVER_TUTORIAL);
if (!query) {
return of$(false);
}
return query.observe().pipe(
switchMap((result) => (result.length ? result[0].observe() : of$(false))),
switchMap((v) => of$(Boolean(v))),
);
};
export const getOnboardingViewed = async (): Promise<boolean> => {
try {
const {database} = DatabaseManager.getAppDatabaseAndOperator();
@@ -52,17 +41,6 @@ export const getOnboardingViewed = async (): Promise<boolean> => {
}
};
export const observeProfileLongPresTutorial = () => {
const query = queryGlobalValue(GLOBAL_IDENTIFIERS.PROFILE_LONG_PRESS_TUTORIAL);
if (!query) {
return of$(false);
}
return query.observe().pipe(
switchMap((result) => (result.length ? result[0].observe() : of$(false))),
switchMap((v) => of$(Boolean(v))),
);
};
export const getLastAskedForReview = async () => {
const records = await queryGlobalValue(GLOBAL_IDENTIFIERS.LAST_ASK_FOR_REVIEW)?.fetch();
if (!records?.[0]?.value) {
@@ -85,3 +63,14 @@ export const getFirstLaunch = async () => {
return records[0].value;
};
export const observeTutorialWatched = (tutorial: string) => {
const query = queryGlobalValue(tutorial);
if (!query) {
return of$(false);
}
return query.observe().pipe(
switchMap((result) => (result.length ? result[0].observe() : of$(false))),
switchMap((v) => of$(Boolean(v))),
);
};

24
app/queries/app/info.ts Normal file
View File

@@ -0,0 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database';
import DatabaseManager from '@database/manager';
import type InfoModel from '@typings/database/models/app/info';
const {APP: {INFO}} = MM_TABLES;
export const getLastInstalledVersion = async () => {
try {
const {database} = DatabaseManager.getAppDatabaseAndOperator();
const infos = await database.get<InfoModel>(INFO).query(
Q.sortBy('created_at', Q.desc),
Q.take(1),
).fetch();
return infos[0];
} catch {
return undefined;
}
};

View File

@@ -158,6 +158,50 @@ export const getConfigValue = async (database: Database, key: keyof ClientConfig
return list.length ? list[0].value : undefined;
};
export const getLastGlobalDataRetentionRun = async (database: Database) => {
try {
const data = await database.get<SystemModel>(SYSTEM).find(SYSTEM_IDENTIFIERS.LAST_DATA_RETENTION_RUN);
return data?.value || 0;
} catch {
return undefined;
}
};
export const getGlobalDataRetentionPolicy = async (database: Database) => {
try {
const data = await database.get<SystemModel>(SYSTEM).find(SYSTEM_IDENTIFIERS.DATA_RETENTION_POLICIES);
return (data?.value || {}) as GlobalDataRetentionPolicy;
} catch {
return undefined;
}
};
export const getGranularDataRetentionPolicies = async (database: Database) => {
try {
const data = await database.get<SystemModel>(SYSTEM).find(SYSTEM_IDENTIFIERS.GRANULAR_DATA_RETENTION_POLICIES);
return (data?.value || {
team: [],
channel: [],
}) as {
team: TeamDataRetentionPolicy[];
channel: ChannelDataRetentionPolicy[];
};
} catch {
return undefined;
}
};
export const getIsDataRetentionEnabled = async (database: Database) => {
const license = await getLicense(database);
if (!license || !Object.keys(license)?.length) {
return null;
}
const dataRetentionEnableMessageDeletion = await getConfigValue(database, 'DataRetentionEnableMessageDeletion');
return dataRetentionEnableMessageDeletion === 'true' && license?.IsLicensed === 'true' && license?.DataRetention === 'true';
};
export const observeConfig = (database: Database): Observable<ClientConfig | undefined> => {
return database.get<ConfigModel>(CONFIG).query().observeWithColumns(['value']).pipe(
switchMap((result) => of$(fromModelToClientConfig(result))),

View File

@@ -3,7 +3,7 @@
import BottomSheetM, {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetFooterProps} from '@gorhom/bottom-sheet';
import React, {ReactNode, useCallback, useEffect, useMemo, useRef} from 'react';
import {DeviceEventEmitter, Keyboard, View} from 'react-native';
import {DeviceEventEmitter, Handle, InteractionManager, Keyboard, StyleProp, View, ViewStyle} from 'react-native';
import useNavButtonPressed from '@app/hooks/navigation_button_pressed';
import {Events} from '@constants';
@@ -24,6 +24,7 @@ export {default as BottomSheetContent, TITLE_HEIGHT} from './content';
type Props = {
closeButtonId?: string;
componentId: string;
contentStyle?: StyleProp<ViewStyle>;
initialSnapIndex?: number;
footerComponent?: React.FC<BottomSheetFooterProps>;
renderContent: () => ReactNode;
@@ -80,16 +81,18 @@ export const animatedConfig: Omit<WithSpringConfig, 'velocity'> = {
const BottomSheet = ({
closeButtonId,
componentId,
contentStyle,
initialSnapIndex = 1,
footerComponent,
renderContent,
snapPoints = [1, '50%', '90%'],
snapPoints = [1, '50%', '80%'],
testID,
}: Props) => {
const sheetRef = useRef<BottomSheetM>(null);
const isTablet = useIsTablet();
const theme = useTheme();
const styles = getStyleSheet(theme);
const interaction = useRef<Handle>();
const bottomSheetBackgroundStyle = useMemo(() => [
styles.bottomSheetBackground,
@@ -112,6 +115,10 @@ const BottomSheet = ({
return () => listener.remove();
}, [close]);
const handleAnimationStart = useCallback(() => {
interaction.current = InteractionManager.createInteractionHandle();
}, []);
const handleClose = useCallback(() => {
if (sheetRef.current) {
sheetRef.current.close();
@@ -120,7 +127,14 @@ const BottomSheet = ({
}
}, []);
const handleDismissIfNeeded = useCallback((index: number) => {
const handleChange = useCallback((index: number) => {
setTimeout(() => {
if (interaction.current) {
InteractionManager.clearInteractionHandle(interaction.current);
interaction.current = undefined;
}
});
if (index <= 0) {
close();
}
@@ -147,7 +161,7 @@ const BottomSheet = ({
const renderContainerContent = () => (
<View
style={[styles.content, isTablet && styles.contentTablet]}
style={[styles.content, isTablet && styles.contentTablet, contentStyle]}
testID={`${testID}.screen`}
>
{renderContent()}
@@ -170,12 +184,15 @@ const BottomSheet = ({
snapPoints={snapPoints}
animateOnMount={true}
backdropComponent={renderBackdrop}
onChange={handleDismissIfNeeded}
onAnimate={handleAnimationStart}
onChange={handleChange}
animationConfigs={animatedConfig}
handleComponent={Indicator}
style={styles.bottomSheet}
backgroundStyle={bottomSheetBackgroundStyle}
footerComponent={footerComponent}
keyboardBehavior='extend'
keyboardBlurBehavior='restore'
>
{renderContainerContent()}
</BottomSheetM>

View File

@@ -6,8 +6,8 @@ import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {General} from '@constants';
import {observeProfileLongPresTutorial} from '@queries/app/global';
import {General, Tutorial} from '@constants';
import {observeTutorialWatched} from '@queries/app/global';
import {observeConfigValue, observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system';
import {observeTeammateNameDisplay} from '@queries/servers/user';
@@ -24,7 +24,7 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
teammateNameDisplay: observeTeammateNameDisplay(database),
currentUserId: observeCurrentUserId(database),
currentTeamId: observeCurrentTeamId(database),
tutorialWatched: observeProfileLongPresTutorial(),
tutorialWatched: observeTutorialWatched(Tutorial.PROFILE_LONG_PRESS),
restrictDirectMessage,
};
});

View File

@@ -9,7 +9,6 @@ import {Edge, SafeAreaView} from 'react-native-safe-area-context';
import {updateLocalCustomStatus} from '@actions/local/user';
import {removeRecentCustomStatus, updateCustomStatus, unsetCustomStatus} from '@actions/remote/user';
import CompassIcon from '@components/compass_icon';
import TabletTitle from '@components/tablet_title';
import {Events, Screens} from '@constants';
import {CustomStatusDurationEnum, SET_CUSTOM_STATUS_FAILURE} from '@constants/custom_status';
@@ -18,7 +17,7 @@ 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, goToScreen, showModal} from '@screens/navigation';
import {dismissModal, goToScreen, openAsBottomSheet, showModal} from '@screens/navigation';
import {getCurrentMomentForTimezone, getRoundedTime} from '@utils/helpers';
import {logDebug} from '@utils/log';
import {mergeNavigationOptions} from '@utils/navigation';
@@ -285,12 +284,12 @@ const CustomStatus = ({
}, [newStatus, isStatusSet, storedStatus, currentUser]);
const openEmojiPicker = useCallback(preventDoubleTap(() => {
CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor).then((source) => {
const screen = Screens.EMOJI_PICKER;
const title = intl.formatMessage({id: 'mobile.custom_status.choose_emoji', defaultMessage: 'Choose an emoji'});
const passProps = {closeButton: source, onEmojiPress: handleEmojiClick};
showModal(screen, title, passProps);
openAsBottomSheet({
closeButtonId: 'close-emoji-picker',
screen: Screens.EMOJI_PICKER,
theme,
title: intl.formatMessage({id: 'mobile.custom_status.choose_emoji', defaultMessage: 'Choose an emoji'}),
props: {onEmojiPress: handleEmojiClick},
});
}), [theme, intl, handleEmojiClick]);

View File

@@ -1,51 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect} from 'react';
import {Keyboard} from 'react-native';
import React, {useCallback} from 'react';
import {DeviceEventEmitter, StyleSheet} from 'react-native';
import EmojiPicker from '@components/emoji_picker';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {dismissModal, setButtons} from '@screens/navigation';
import {Events} from '@constants';
import BottomSheet from '@screens/bottom_sheet';
import Picker from './picker';
import PickerFooter from './picker/footer';
type Props = {
componentId: string;
onEmojiPress: (emoji: string) => void;
closeButton: never;
closeButtonId: string;
};
const EMOJI_PICKER_BUTTON = 'close-add-reaction';
const EmojiPickerScreen = ({closeButton, componentId, onEmojiPress}: Props) => {
useEffect(() => {
setButtons(componentId, {
leftButtons: [
{
icon: closeButton,
id: EMOJI_PICKER_BUTTON,
testID: 'close.emoji_picker.button',
},
],
rightButtons: [],
});
}, []);
const close = () => {
Keyboard.dismiss();
dismissModal({componentId});
};
useNavButtonPressed(EMOJI_PICKER_BUTTON, componentId, close, []);
const style = StyleSheet.create({
contentStyle: {
paddingTop: 14,
},
});
const EmojiPickerScreen = ({closeButtonId, componentId, onEmojiPress}: Props) => {
const handleEmojiPress = useCallback((emoji: string) => {
onEmojiPress(emoji);
close();
DeviceEventEmitter.emit(Events.CLOSE_BOTTOM_SHEET);
}, []);
const renderContent = useCallback(() => {
return (
<Picker
onEmojiPress={handleEmojiPress}
testID='emoji_picker'
/>
);
}, []);
return (
<EmojiPicker
onEmojiPress={handleEmojiPress}
testID='emoji_picker'
<BottomSheet
renderContent={renderContent}
closeButtonId={closeButtonId}
componentId={componentId}
contentStyle={style.contentStyle}
initialSnapIndex={1}
footerComponent={PickerFooter}
testID='post_options'
/>
);
};

View File

@@ -18,28 +18,31 @@ type Props = {
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
width: 32,
height: 32,
alignItems: 'center',
flex: 1,
height: 35,
justifyContent: 'center',
zIndex: 10,
},
icon: {
color: changeOpacity(theme.centerChannelColor, 0.4),
color: changeOpacity(theme.centerChannelColor, 0.56),
},
selectedContainer: {
backgroundColor: changeOpacity(theme.buttonBg, 0.08),
borderRadius: 4,
},
selected: {
color: theme.centerChannelColor,
color: theme.buttonBg,
},
}));
const SectionIcon = ({currentIndex, icon, index, scrollToIndex, theme}: Props) => {
const EmojiCategoryBarIcon = ({currentIndex, icon, index, scrollToIndex, theme}: Props) => {
const style = getStyleSheet(theme);
const onPress = useCallback(preventDoubleTap(() => scrollToIndex(index)), []);
return (
<TouchableOpacity
onPress={onPress}
style={style.container}
style={[style.container, currentIndex === index ? style.selectedContainer : undefined]}
>
<CompassIcon
name={icon}
@@ -50,4 +53,4 @@ const SectionIcon = ({currentIndex, icon, index, scrollToIndex, theme}: Props) =
);
};
export default SectionIcon;
export default EmojiCategoryBarIcon;

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {View} from 'react-native';
import {useTheme} from '@context/theme';
import {selectEmojiCategoryBarSection, useEmojiCategoryBar} from '@hooks/emoji_category_bar';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import EmojiCategoryBarIcon from './icon';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
justifyContent: 'space-between',
backgroundColor: theme.centerChannelBg,
height: 55,
paddingHorizontal: 12,
paddingTop: 11,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.08),
borderTopWidth: 1,
flexDirection: 'row',
},
}));
type Props = {
onSelect?: (index: number | undefined) => void;
}
const EmojiCategoryBar = ({onSelect}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const {currentIndex, icons} = useEmojiCategoryBar();
const scrollToIndex = useCallback((index: number) => {
if (onSelect) {
onSelect(index);
return;
}
selectEmojiCategoryBarSection(index);
}, []);
if (!icons) {
return null;
}
return (
<View
style={styles.container}
testID='emoji_picker.category_bar'
>
{icons.map((icon, index) => (
<EmojiCategoryBarIcon
currentIndex={currentIndex}
key={icon.key}
icon={icon.icon}
index={index}
scrollToIndex={scrollToIndex}
theme={theme}
/>
))}
</View>
);
};
export default EmojiCategoryBar;

View File

@@ -1,11 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BottomSheetFlatList} from '@gorhom/bottom-sheet';
import Fuse from 'fuse.js';
import React, {useCallback, useMemo} from 'react';
import {FlatList, ListRenderItemInfo, StyleSheet, View} from 'react-native';
import NoResultsWithTerm from '@components/no_results_with_term';
import {useIsTablet} from '@hooks/device';
import {getEmojis, searchEmojis} from '@utils/emoji/helpers';
import EmojiItem from './emoji_item';
@@ -14,7 +16,6 @@ import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji
type Props = {
customEmojis: CustomEmojiModel[];
keyboardHeight: number;
skinTone: string;
searchTerm: string;
onEmojiPress: (emojiName: string) => void;
@@ -28,9 +29,9 @@ const style = StyleSheet.create({
},
});
const EmojiFiltered = ({customEmojis, keyboardHeight, skinTone, searchTerm, onEmojiPress}: Props) => {
const EmojiFiltered = ({customEmojis, skinTone, searchTerm, onEmojiPress}: Props) => {
const isTablet = useIsTablet();
const emojis = useMemo(() => getEmojis(skinTone, customEmojis), [skinTone, customEmojis]);
const flatListStyle = useMemo(() => ({flexGrow: 1, paddingBottom: keyboardHeight}), [keyboardHeight]);
const fuse = useMemo(() => {
const options = {findAllMatches: true, ignoreLocation: true, includeMatches: true, shouldSort: false, includeScore: true};
@@ -45,6 +46,8 @@ const EmojiFiltered = ({customEmojis, keyboardHeight, skinTone, searchTerm, onEm
return searchEmojis(fuse, searchTerm);
}, [fuse, searchTerm]);
const List = useMemo(() => (isTablet ? FlatList : BottomSheetFlatList), [isTablet]);
const keyExtractor = useCallback((item: string) => item, []);
const renderEmpty = useCallback(() => {
@@ -65,8 +68,7 @@ const EmojiFiltered = ({customEmojis, keyboardHeight, skinTone, searchTerm, onEm
}, []);
return (
<FlatList
contentContainerStyle={flatListStyle}
<List
data={data}
initialNumToRender={30}
keyboardDismissMode='interactive'

View File

@@ -0,0 +1,23 @@
// 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 {Preferences} from '@constants';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import EmojiFiltered from './filtered';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
skinTone: queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_EMOJI, Preferences.EMOJI_SKINTONE).
observeWithColumns(['value']).pipe(
switchMap((prefs) => of$(prefs?.[0]?.value ?? 'default')),
),
}));
export default withDatabase(enhanced(EmojiFiltered));

View File

@@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BottomSheetFooter, BottomSheetFooterProps, SHEET_STATE, useBottomSheet, useBottomSheetInternal} from '@gorhom/bottom-sheet';
import React, {useCallback} from 'react';
import {Platform} from 'react-native';
import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated';
import {useTheme} from '@context/theme';
import {useKeyboardHeight} from '@hooks/device';
import {selectEmojiCategoryBarSection} from '@hooks/emoji_category_bar';
import EmojiCategoryBar from '../emoji_category_bar';
const PickerFooter = (props: BottomSheetFooterProps) => {
const theme = useTheme();
const keyboardHeight = useKeyboardHeight();
const {animatedSheetState} = useBottomSheetInternal();
const {expand} = useBottomSheet();
const scrollToIndex = useCallback((index: number) => {
if (animatedSheetState.value === SHEET_STATE.EXTENDED) {
selectEmojiCategoryBarSection(index);
return;
}
expand();
// @ts-expect-error wait until the bottom sheet is epanded
while (animatedSheetState.value !== SHEET_STATE.EXTENDED) {
// do nothing
}
selectEmojiCategoryBarSection(index);
}, []);
const animatedStyle = useAnimatedStyle(() => {
const paddingBottom = withTiming(
Platform.OS === 'ios' ? 20 : 0,
{duration: 250},
);
return {backgroundColor: theme.centerChannelBg, paddingBottom};
}, [theme]);
const heightAnimatedStyle = useAnimatedStyle(() => {
let height = 55;
if (keyboardHeight === 0 && Platform.OS === 'ios') {
height += 20;
} else if (keyboardHeight) {
height = 0;
}
return {
height,
};
}, [keyboardHeight]);
return (
<BottomSheetFooter
style={heightAnimatedStyle}
{...props}
>
<Animated.View style={[animatedStyle]}>
<EmojiCategoryBar onSelect={scrollToIndex}/>
</Animated.View>
</BottomSheetFooter>
);
};
export default PickerFooter;

View File

@@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useBottomSheet} from '@gorhom/bottom-sheet';
import React, {useCallback} from 'react';
import {NativeSyntheticEvent, TextInputFocusEventData} from 'react-native';
import SearchBar, {SearchProps} from '@components/search';
const BottomSheetSearch = ({onFocus, ...props}: SearchProps) => {
const {expand} = useBottomSheet();
const handleOnFocus = useCallback((event: NativeSyntheticEvent<TextInputFocusEventData>) => {
expand();
onFocus?.(event);
}, [onFocus, expand]);
return (
<SearchBar
onFocus={handleOnFocus}
{...props}
/>
);
};
export default BottomSheetSearch;

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect} from 'react';
import {LayoutChangeEvent, StyleSheet, View} from 'react-native';
import {useSharedValue} from 'react-native-reanimated';
import SearchBar, {SearchProps} from '@components/search';
import {useIsTablet} from '@hooks/device';
import {setEmojiSkinTone} from '@hooks/emoji_category_bar';
import BottomSheetSearch from './bottom_sheet_search';
import SkinToneSelector from './skintone_selector';
type Props = SearchProps & {
skinTone: string;
}
const styles = StyleSheet.create({
flex: {flex: 1},
row: {flexDirection: 'row'},
});
const PickerHeader = ({skinTone, ...props}: Props) => {
const isTablet = useIsTablet();
const containerWidth = useSharedValue(0);
const isSearching = useSharedValue(false);
useEffect(() => {
const req = requestAnimationFrame(() => {
setEmojiSkinTone(skinTone);
});
return () => cancelAnimationFrame(req);
}, [skinTone]);
const onBlur = useCallback(() => {
isSearching.value = false;
}, []);
const onFocus = useCallback(() => {
isSearching.value = true;
}, []);
const onLayout = useCallback((e: LayoutChangeEvent) => {
containerWidth.value = e.nativeEvent.layout.width;
}, []);
let search;
if (isTablet) {
search = (
<SearchBar
{...props}
onBlur={onBlur}
onFocus={onFocus}
/>
);
} else {
search = (
<BottomSheetSearch
{...props}
onBlur={onBlur}
onFocus={onFocus}
/>
);
}
return (
<View
onLayout={onLayout}
style={styles.row}
>
<View style={styles.flex}>
{search}
</View>
<SkinToneSelector
skinTone={skinTone}
containerWidth={containerWidth}
isSearching={isSearching}
/>
</View>
);
};
export default PickerHeader;

View File

@@ -0,0 +1,23 @@
// 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 {Preferences} from '@constants';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import PickerHeader from './header';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
skinTone: queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_EMOJI, Preferences.EMOJI_SKINTONE).
observeWithColumns(['value']).pipe(
switchMap((prefs) => of$(prefs?.[0]?.value ?? 'default')),
),
}));
export default withDatabase(enhanced(PickerHeader));

View File

@@ -0,0 +1,33 @@
// 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 CompassIcon from '@components/compass_icon';
import {useTheme} from '@context/theme';
import {changeOpacity} from '@utils/theme';
type Props = {
collapse: () => void;
};
const hitSlop = {top: 10, bottom: 10, left: 10, right: 10};
const CloseButton = ({collapse}: Props) => {
const theme = useTheme();
return (
<TouchableOpacity
hitSlop={hitSlop}
onPress={collapse}
>
<CompassIcon
name='close'
size={24}
color={changeOpacity(theme.centerChannelColor, 0.56)}
/>
</TouchableOpacity>
);
};
export default CloseButton;

View File

@@ -0,0 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import withObservables from '@nozbe/with-observables';
import {Tutorial} from '@constants';
import {observeTutorialWatched} from '@queries/app/global';
import SkinToneSelector from './skintone_selector';
const enhance = withObservables([], () => ({
tutorialWatched: observeTutorialWatched(Tutorial.EMOJI_SKIN_SELECTOR),
}));
export default enhance(SkinToneSelector);

View File

@@ -0,0 +1,91 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {View} from 'react-native';
import {savePreferredSkinTone} from '@actions/remote/preference';
import FormattedText from '@components/formatted_text';
import TouchableEmoji from '@components/touchable_emoji';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {skinCodes} from '@utils/emoji';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
onSelectSkin: () => void;
selected: string;
skins: Record<string, string>;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
width: 42,
alignItems: 'center',
justifyContent: 'center',
},
selected: {
backgroundColor: changeOpacity(theme.buttonBg, 0.08),
borderRadius: 4,
},
skins: {
flex: 1,
flexDirection: 'row',
justifyContent: 'space-between',
},
textContainer: {
marginHorizontal: 16,
maxWidth: 57,
},
text: {
color: theme.centerChannelColor,
...typography('Body', 75, 'SemiBold'),
},
}));
const SkinSelector = ({onSelectSkin, selected, skins}: Props) => {
const isTablet = useIsTablet();
const theme = useTheme();
const serverUrl = useServerUrl();
const styles = getStyleSheet(theme);
const handleSelectSkin = useCallback(async (emoji: string) => {
const skin = emoji.split('hand_')[1] || 'default';
const code = Object.keys(skinCodes).find((key) => skinCodes[key] === skin) || 'default';
await savePreferredSkinTone(serverUrl, code);
onSelectSkin();
}, [serverUrl]);
return (
<>
<View style={[styles.textContainer, isTablet && {marginLeft: 0}]}>
<FormattedText
id='default_skin_tone'
defaultMessage='Default Skin Tone'
style={styles.text}
/>
</View>
<View style={[styles.skins, isTablet && {marginRight: 10}]}>
{Object.keys(skins).map((key) => {
const name = skins[key];
return (
<View
key={name}
style={[styles.container, selected === key && styles.selected]}
>
<TouchableEmoji
name={name}
size={28}
onEmojiPress={handleSelectSkin}
/>
</View>
);
})}
</View>
</>
);
};
export default SkinSelector;

View File

@@ -0,0 +1,182 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {Platform, StyleSheet} from 'react-native';
import Animated, {
EntryAnimationsValues, ExitAnimationsValues, FadeIn, FadeOut,
SharedValue, useAnimatedStyle, withDelay, withTiming,
} from 'react-native-reanimated';
import Tooltip from 'react-native-walkthrough-tooltip';
import {storeSkinEmojiSelectorTutorial} from '@actions/app/global';
import TouchableEmoji from '@components/touchable_emoji';
import {useIsTablet} from '@hooks/device';
import {skinCodes} from '@utils/emoji';
import CloseButton from './close_button';
import SkinSelector from './skin_selector';
import SkinSelectorTooltip from './tooltip';
type Props = {
containerWidth: SharedValue<number>;
isSearching: SharedValue<boolean>;
skinTone?: string;
tutorialWatched: boolean;
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
},
expanded: {
alignItems: 'center',
flexDirection: 'row',
width: '100%',
zIndex: 2,
},
tooltipStyle: {
shadowColor: '#000',
shadowOffset: {width: 0, height: 2},
shadowRadius: 2,
shadowOpacity: 0.16,
},
});
const skins = Object.keys(skinCodes).reduce<Record<string, string>>((result, value) => {
const skin = skinCodes[value];
if (value === 'default') {
result[value] = 'hand';
} else {
result[value] = `hand_${skin}`;
}
return result;
}, {});
const SkinToneSelector = ({skinTone = 'default', containerWidth, isSearching, tutorialWatched}: Props) => {
const [expanded, setExpanded] = useState(false);
const [tooltipVisible, setTooltipVisible] = useState(false);
const isTablet = useIsTablet();
const tooltipContentStyle = useMemo(() => ({
borderRadius: 8,
maxWidth: isTablet ? 352 : undefined,
padding: 0,
}), [isTablet]);
const exiting = useCallback((values: ExitAnimationsValues) => {
'worklet';
const animations = {
originX: withTiming(containerWidth.value, {duration: 250}),
opacity: withTiming(0, {duration: 250}),
};
const initialValues = {
originX: values.currentOriginX,
opacity: 1,
};
return {
initialValues,
animations,
};
}, [containerWidth.value]);
const entering = useCallback((values: EntryAnimationsValues) => {
'worklet';
const animations = {
originX: withTiming(values.targetOriginX, {duration: 250}),
opacity: withTiming(1, {duration: 300}),
};
const initialValues = {
originX: containerWidth.value - 122,
opacity: 0,
};
return {
initialValues,
animations,
};
}, [containerWidth.value]);
const collapse = useCallback(() => {
setExpanded(false);
}, []);
const expand = useCallback(() => {
setExpanded(true);
}, []);
const close = useCallback(() => {
setTooltipVisible(false);
storeSkinEmojiSelectorTutorial();
}, []);
const widthAnimatedStyle = useAnimatedStyle(() => {
return {
width: withDelay(isSearching.value ? 0 : 700, withTiming(isSearching.value ? 0 : 32, {duration: isSearching.value ? 50 : 300})),
marginLeft: Platform.OS === 'android' ? 10 : undefined,
};
}, []);
const opacityStyle = useAnimatedStyle(() => {
return {
opacity: withDelay(isSearching.value ? 0 : 700, withTiming(isSearching.value ? 0 : 1, {duration: isSearching.value ? 50 : 350})),
};
}, []);
useEffect(() => {
const t = setTimeout(() => {
if (!tutorialWatched) {
setTooltipVisible(true);
}
}, 750);
return () => clearTimeout(t);
}, []);
return (
<>
{!expanded &&
<Tooltip
isVisible={tooltipVisible}
useInteractionManager={true}
contentStyle={tooltipContentStyle}
content={<SkinSelectorTooltip onClose={close}/>}
placement={isTablet ? 'left' : 'top'}
onClose={close}
tooltipStyle={styles.tooltipStyle}
>
<Animated.View
style={widthAnimatedStyle}
exiting={FadeOut}
entering={FadeIn}
>
<Animated.View style={[styles.container, opacityStyle]}>
<TouchableEmoji
name={skins[skinTone]}
onEmojiPress={expand}
size={28}
/>
</Animated.View>
</Animated.View>
</Tooltip>
}
{expanded &&
<Animated.View
style={styles.expanded}
entering={entering}
exiting={exiting}
>
{!isTablet && <CloseButton collapse={collapse}/>}
<SkinSelector
selected={skinTone}
skins={skins}
onSelectSkin={collapse}
/>
{isTablet && <CloseButton collapse={collapse}/>}
</Animated.View>
}
</>
);
};
export default SkinToneSelector;

View File

@@ -0,0 +1,79 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet, TouchableOpacity, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {Preferences} from '@constants';
import {changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
onClose: () => void;
}
const hitSlop = {top: 10, bottom: 10, left: 10, right: 10};
const styles = StyleSheet.create({
container: {
marginHorizontal: 24,
},
close: {
flex: 1,
alignItems: 'flex-end',
marginLeft: 11,
},
descriptionContainer: {
marginBottom: 24,
marginTop: 12,
},
description: {
color: Preferences.THEMES.denim.centerChannelColor,
...typography('Body', 200, 'Regular'),
},
titleContainer: {
alignItems: 'center',
flexDirection: 'row',
marginTop: 22,
},
title: {
color: Preferences.THEMES.denim.centerChannelColor,
...typography('Body', 200, 'SemiBold'),
},
});
const SkinSelectorTooltip = ({onClose}: Props) => {
return (
<View style={styles.container}>
<View style={styles.titleContainer}>
<FormattedText
id='skintone_selector.tooltip.title'
defaultMessage='Choose your default skin tone'
style={styles.title}
/>
<TouchableOpacity
style={styles.close}
hitSlop={hitSlop}
onPress={onClose}
>
<CompassIcon
color={changeOpacity(Preferences.THEMES.denim.centerChannelColor, 0.56)}
name='close'
size={18}
/>
</TouchableOpacity>
</View>
<View style={styles.descriptionContainer}>
<FormattedText
id='skintone_selector.tooltip.description'
defaultMessage='You can now choose the skin tone you prefer to use for your emojis.'
style={styles.description}
/>
</View>
</View>
);
};
export default SkinSelectorTooltip;

View File

@@ -0,0 +1,20 @@
// 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 {queryAllCustomEmojis} from '@queries/servers/custom_emoji';
import {observeConfigBooleanValue, observeRecentReactions} from '@queries/servers/system';
import Picker from './picker';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
customEmojisEnabled: observeConfigBooleanValue(database, 'EnableCustomEmoji'),
customEmojis: queryAllCustomEmojis(database).observe(),
recentEmojis: observeRecentReactions(database),
}));
export default withDatabase(enhanced(Picker));

View File

@@ -1,47 +1,29 @@
// 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 React, {useCallback, useState} from 'react';
import {LayoutChangeEvent, Platform, StyleSheet, View} from 'react-native';
import {Edge, SafeAreaView} from 'react-native-safe-area-context';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {StyleSheet, View} from 'react-native';
import {searchCustomEmojis} from '@actions/remote/custom_emoji';
import SearchBar from '@components/search';
import {Preferences} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {debounce} from '@helpers/api/general';
import {useKeyboardHeight} from '@hooks/device';
import {queryAllCustomEmojis} from '@queries/servers/custom_emoji';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {observeConfigBooleanValue, observeRecentReactions} from '@queries/servers/system';
import {getKeyboardAppearanceFromTheme} from '@utils/theme';
import EmojiFiltered from './filtered';
import PickerHeader from './header';
import EmojiSections from './sections';
import type {WithDatabaseArgs} from '@typings/database/database';
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
export const SCROLLVIEW_NATIVE_ID = 'emojiSelector';
const edges: Edge[] = ['bottom', 'left', 'right'];
const styles = StyleSheet.create({
flex: {
flex: 1,
},
container: {
flex: 1,
marginHorizontal: 12,
},
searchBar: {
paddingVertical: 5,
marginLeft: 12,
marginRight: Platform.select({ios: 4, default: 12}),
paddingBottom: 5,
},
});
@@ -50,22 +32,21 @@ type Props = {
customEmojisEnabled: boolean;
onEmojiPress: (emoji: string) => void;
recentEmojis: string[];
skinTone: string;
testID?: string;
}
const EmojiPicker = ({customEmojis, customEmojisEnabled, onEmojiPress, recentEmojis, skinTone, testID = ''}: Props) => {
const Picker = ({customEmojis, customEmojisEnabled, onEmojiPress, recentEmojis, testID = ''}: Props) => {
const theme = useTheme();
const serverUrl = useServerUrl();
const keyboardHeight = useKeyboardHeight();
const [width, setWidth] = useState(0);
const [searchTerm, setSearchTerm] = useState<string|undefined>();
const onLayout = useCallback(({nativeEvent}: LayoutChangeEvent) => setWidth(nativeEvent.layout.width), []);
const onCancelSearch = useCallback(() => setSearchTerm(undefined), []);
const onChangeSearchTerm = useCallback((text: string) => {
setSearchTerm(text);
searchCustom(text);
}, []);
const searchCustom = debounce((text: string) => {
if (text && text.length > 1) {
searchCustomEmojis(serverUrl, text);
@@ -77,8 +58,6 @@ const EmojiPicker = ({customEmojis, customEmojisEnabled, onEmojiPress, recentEmo
EmojiList = (
<EmojiFiltered
customEmojis={customEmojis}
keyboardHeight={keyboardHeight}
skinTone={skinTone}
searchTerm={searchTerm}
onEmojiPress={onEmojiPress}
/>
@@ -90,20 +69,17 @@ const EmojiPicker = ({customEmojis, customEmojisEnabled, onEmojiPress, recentEmo
customEmojisEnabled={customEmojisEnabled}
onEmojiPress={onEmojiPress}
recentEmojis={recentEmojis}
skinTone={skinTone}
width={width}
/>
);
}
return (
<SafeAreaView
<View
style={styles.flex}
edges={edges}
testID={`${testID}.screen`}
>
<View style={styles.searchBar}>
<SearchBar
<PickerHeader
autoCapitalize='none'
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
onCancel={onCancelSearch}
@@ -112,28 +88,9 @@ const EmojiPicker = ({customEmojis, customEmojisEnabled, onEmojiPress, recentEmo
value={searchTerm}
/>
</View>
<View
style={styles.container}
onLayout={onLayout}
>
{Boolean(width) &&
<>
{EmojiList}
</>
}
</View>
</SafeAreaView>
{EmojiList}
</View>
);
};
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
customEmojisEnabled: observeConfigBooleanValue(database, 'EnableCustomEmoji'),
customEmojis: queryAllCustomEmojis(database).observe(),
recentEmojis: observeRecentReactions(database),
skinTone: queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_EMOJI, Preferences.EMOJI_SKINTONE).
observeWithColumns(['value']).pipe(
switchMap((prefs) => of$(prefs?.[0]?.value ?? 'default')),
),
}));
export default withDatabase(enhanced(EmojiPicker));
export default Picker;

View File

@@ -1,31 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BottomSheetSectionList} from '@gorhom/bottom-sheet';
import {chunk} from 'lodash';
import React, {useCallback, useMemo, useRef, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {ListRenderItemInfo, NativeScrollEvent, NativeSyntheticEvent, SectionList, SectionListData, StyleSheet, View} from 'react-native';
import sectionListGetItemLayout from 'react-native-section-list-get-item-layout';
import {fetchCustomEmojis} from '@actions/remote/custom_emoji';
import TouchableEmoji from '@components/touchable_emoji';
import {EMOJIS_PER_PAGE} from '@constants/emoji';
import {useServerUrl} from '@context/server';
import {useIsTablet} from '@hooks/device';
import {setEmojiCategoryBarIcons, setEmojiCategoryBarSection, useEmojiCategoryBar} from '@hooks/emoji_category_bar';
import {CategoryNames, EmojiIndicesByCategory, CategoryTranslations, CategoryMessage} from '@utils/emoji';
import {fillEmoji} from '@utils/emoji/helpers';
import EmojiSectionBar, {SCROLLVIEW_NATIVE_ID, SectionIconType} from './icons_bar';
import EmojiCategoryBar from '../emoji_category_bar';
import SectionFooter from './section_footer';
import SectionHeader, {SECTION_HEADER_HEIGHT} from './section_header';
import TouchableEmoji from './touchable_emoji';
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
export const EMOJI_SIZE = 30;
export const EMOJI_GUTTER = 8;
const EMOJI_SIZE = 34;
const EMOJIS_PER_ROW = 7;
const EMOJIS_PER_ROW_TABLET = 9;
const EMOJI_ROW_MARGIN = 12;
const ICONS: Record<string, string> = {
recent: 'clock-outline',
'smileys-emotion': 'emoticon-happy-outline',
'people-body': 'eye-outline',
'people-body': 'account-outline',
'animals-nature': 'leaf-outline',
'food-drink': 'food-apple',
'travel-places': 'airplane-variant',
@@ -37,20 +43,27 @@ const ICONS: Record<string, string> = {
};
const categoryToI18n: Record<string, CategoryTranslation> = {};
let emojiSectionsByOffset: number[] = [];
const getItemLayout = sectionListGetItemLayout({
getItemHeight: () => (EMOJI_SIZE + (EMOJI_GUTTER * 2)),
getItemHeight: () => EMOJI_SIZE + EMOJI_ROW_MARGIN,
getSectionHeaderHeight: () => SECTION_HEADER_HEIGHT,
sectionOffsetsCallback: (offsetsById) => {
emojiSectionsByOffset = offsetsById;
},
});
const styles = StyleSheet.create(({
flex: {flex: 1},
contentContainerStyle: {paddingBottom: 50},
row: {
flexDirection: 'row',
marginBottom: EMOJI_GUTTER,
justifyContent: 'space-between',
marginBottom: EMOJI_ROW_MARGIN,
},
emoji: {
height: EMOJI_SIZE + EMOJI_GUTTER,
marginHorizontal: 7,
width: EMOJI_SIZE + EMOJI_GUTTER,
height: EMOJI_SIZE,
width: EMOJI_SIZE,
},
}));
@@ -59,8 +72,6 @@ type Props = {
customEmojisEnabled: boolean;
onEmojiPress: (emoji: string) => void;
recentEmojis: string[];
skinTone: string;
width: number;
}
CategoryNames.forEach((name: string) => {
@@ -73,27 +84,34 @@ CategoryNames.forEach((name: string) => {
}
});
const EmojiSections = ({customEmojis, customEmojisEnabled, onEmojiPress, recentEmojis, skinTone, width}: Props) => {
const emptyEmoji: EmojiAlias = {
name: '',
short_name: '',
aliases: [],
};
const EmojiSections = ({customEmojis, customEmojisEnabled, onEmojiPress, recentEmojis}: Props) => {
const serverUrl = useServerUrl();
const isTablet = useIsTablet();
const {currentIndex, selectedIndex} = useEmojiCategoryBar();
const list = useRef<SectionList<EmojiSection>>(null);
const [sectionIndex, setSectionIndex] = useState(0);
const categoryIndex = useRef(currentIndex);
const [customEmojiPage, setCustomEmojiPage] = useState(0);
const [fetchingCustomEmojis, setFetchingCustomEmojis] = useState(false);
const [loadedAllCustomEmojis, setLoadedAllCustomEmojis] = useState(false);
const offset = useRef(0);
const manualScroll = useRef(false);
const sections: EmojiSection[] = useMemo(() => {
if (!width) {
return [];
}
const chunkSize = Math.floor(width / (EMOJI_SIZE + EMOJI_GUTTER));
const emojisPerRow = isTablet ? EMOJIS_PER_ROW_TABLET : EMOJIS_PER_ROW;
return CategoryNames.map((category) => {
const emojiIndices = EmojiIndicesByCategory.get(skinTone)?.get(category);
const emojiIndices = EmojiIndicesByCategory.get('default')?.get(category);
let data: EmojiAlias[][];
switch (category) {
case 'custom': {
const builtInCustom = emojiIndices.map(fillEmoji);
const builtInCustom = emojiIndices.map(fillEmoji.bind(null, 'custom'));
// eslint-disable-next-line max-nested-callbacks
const custom = customEmojisEnabled ? customEmojis.map((ce) => ({
@@ -102,7 +120,7 @@ const EmojiSections = ({customEmojis, customEmojisEnabled, onEmojiPress, recentE
short_name: '',
})) : [];
data = chunk<EmojiAlias>(builtInCustom.concat(custom), chunkSize);
data = chunk<EmojiAlias>(builtInCustom.concat(custom), emojisPerRow);
break;
}
case 'recent':
@@ -111,36 +129,34 @@ const EmojiSections = ({customEmojis, customEmojisEnabled, onEmojiPress, recentE
aliases: [],
name: emoji,
short_name: '',
})), chunkSize);
})), EMOJIS_PER_ROW);
break;
default:
data = chunk(emojiIndices.map(fillEmoji), chunkSize);
data = chunk(emojiIndices.map(fillEmoji.bind(null, category)), emojisPerRow);
break;
}
for (const d of data) {
if (d.length < emojisPerRow) {
d.push(
...(new Array(emojisPerRow - d.length).fill(emptyEmoji)),
);
}
}
return {
...categoryToI18n[category],
data,
key: category,
};
}).filter((s: EmojiSection) => s.data.length);
}, [skinTone, customEmojis, customEmojisEnabled, width]);
}, [customEmojis, customEmojisEnabled, isTablet]);
const sectionIcons: SectionIconType[] = useMemo(() => {
return sections.map((s) => ({
useEffect(() => {
setEmojiCategoryBarIcons(sections.map((s) => ({
key: s.key,
icon: s.icon,
}));
}, [sections]);
const emojiSectionsByOffset = useMemo(() => {
let lastOffset = 0;
return sections.map((s) => {
const start = lastOffset;
const nextOffset = s.data.length * (EMOJI_SIZE + (EMOJI_GUTTER * 2));
lastOffset += nextOffset;
return start;
});
})));
}, [sections]);
const onLoadMoreCustomEmojis = useCallback(async () => {
@@ -160,24 +176,31 @@ const EmojiSections = ({customEmojis, customEmojisEnabled, onEmojiPress, recentE
const onScroll = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
const {contentOffset} = e.nativeEvent;
let nextIndex = emojiSectionsByOffset.findIndex(
(offset) => contentOffset.y <= offset,
);
const direction = contentOffset.y > offset.current ? 'up' : 'down';
offset.current = contentOffset.y;
if (nextIndex === -1) {
nextIndex = emojiSectionsByOffset.length - 1;
} else if (nextIndex !== 0) {
nextIndex -= 1;
if (manualScroll.current) {
return;
}
if (nextIndex !== sectionIndex) {
setSectionIndex(nextIndex);
const nextIndex = contentOffset.y >= emojiSectionsByOffset[categoryIndex.current + 1] - SECTION_HEADER_HEIGHT ? categoryIndex.current + 1 : categoryIndex.current;
const prevIndex = Math.max(0, contentOffset.y <= emojiSectionsByOffset[categoryIndex.current] - SECTION_HEADER_HEIGHT ? categoryIndex.current - 1 : categoryIndex.current);
if (nextIndex > categoryIndex.current && direction === 'up') {
categoryIndex.current = nextIndex;
setEmojiCategoryBarSection(nextIndex);
} else if (prevIndex < categoryIndex.current && direction === 'down') {
categoryIndex.current = prevIndex;
setEmojiCategoryBarSection(prevIndex);
}
}, [emojiSectionsByOffset, sectionIndex]);
}, []);
const scrollToIndex = (index: number) => {
manualScroll.current = true;
list.current?.scrollToLocation({sectionIndex: index, itemIndex: 0, animated: false, viewOffset: 0});
setSectionIndex(index);
setEmojiCategoryBarSection(index);
setTimeout(() => {
manualScroll.current = false;
}, 350);
};
const renderSectionHeader = useCallback(({section}: {section: SectionListData<EmojiAlias[], EmojiSection>}) => {
@@ -193,14 +216,22 @@ const EmojiSections = ({customEmojis, customEmojisEnabled, onEmojiPress, recentE
const renderItem = useCallback(({item}: ListRenderItemInfo<EmojiAlias[]>) => {
return (
<View style={styles.row}>
{item.map((emoji: EmojiAlias) => {
{item.map((emoji: EmojiAlias, index: number) => {
if (!emoji.name && !emoji.short_name) {
return (
<View
key={`empty-${index.toString()}`}
style={styles.emoji}
/>
);
}
return (
<TouchableEmoji
key={emoji.name}
name={emoji.name}
onEmojiPress={onEmojiPress}
size={EMOJI_SIZE}
style={styles.emoji}
category={emoji.category}
/>
);
})}
@@ -208,16 +239,23 @@ const EmojiSections = ({customEmojis, customEmojisEnabled, onEmojiPress, recentE
);
}, []);
const List = useMemo(() => (isTablet ? SectionList : BottomSheetSectionList), [isTablet]);
useEffect(() => {
if (selectedIndex != null) {
scrollToIndex(selectedIndex);
}
}, [selectedIndex]);
return (
<>
<SectionList
<View style={styles.flex}>
<List
// @ts-expect-error bottom sheet definition
getItemLayout={getItemLayout}
initialNumToRender={20}
keyboardDismissMode='interactive'
keyboardShouldPersistTaps='always'
ListFooterComponent={renderFooter}
maxToRenderPerBatch={20}
nativeID={SCROLLVIEW_NATIVE_ID}
onEndReached={onLoadMoreCustomEmojis}
onEndReachedThreshold={2}
onScroll={onScroll}
@@ -225,16 +263,15 @@ const EmojiSections = ({customEmojis, customEmojisEnabled, onEmojiPress, recentE
renderItem={renderItem}
renderSectionHeader={renderSectionHeader}
sections={sections}
contentContainerStyle={{paddingBottom: 50}}
windowSize={100}
contentContainerStyle={styles.contentContainerStyle}
stickySectionHeadersEnabled={true}
showsVerticalScrollIndicator={false}
testID='emoji_picker.emoji_sections.section_list'
/>
<EmojiSectionBar
currentIndex={sectionIndex}
scrollToIndex={scrollToIndex}
sections={sectionIcons}
/>
</>
{isTablet &&
<EmojiCategoryBar/>
}
</View>
);
};

View File

@@ -7,6 +7,7 @@ import {View} 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';
type Props = {
section: EmojiSection;
@@ -23,8 +24,8 @@ const getStyleSheetFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
},
sectionTitle: {
color: changeOpacity(theme.centerChannelColor, 0.2),
fontSize: 15,
fontWeight: '700',
textTransform: 'uppercase',
...typography('Heading', 75, 'SemiBold'),
},
};
});

View File

@@ -39,44 +39,87 @@ exports[`components/categories_list should render channels error 1`] = `
<View
style={
{
"alignItems": "center",
"flexDirection": "row",
"justifyContent": "space-between",
}
}
>
<View
accessible={true}
focusable={false}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
{
"alignItems": "center",
"flexDirection": "row",
"justifyContent": "space-between",
"width": "85%",
}
}
>
<Text
<View
style={
{
"color": "#ffffff",
"fontFamily": "Metropolis-SemiBold",
"fontSize": 28,
"fontWeight": "600",
"lineHeight": 36,
"alignItems": "center",
"flexDirection": "row",
"justifyContent": "space-between",
}
}
testID="channel_list_header.team_display_name"
>
Test Team!
</Text>
<View
accessible={true}
focusable={false}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
{
"alignItems": "center",
"flexDirection": "row",
"justifyContent": "space-between",
}
}
>
<Text
ellipsizeMode="tail"
numberOfLines={2}
style={
{
"color": "#ffffff",
"fontFamily": "Metropolis-SemiBold",
"fontSize": 28,
"fontWeight": "600",
"lineHeight": 36,
}
}
testID="channel_list_header.team_display_name"
>
Test Team!
</Text>
</View>
</View>
<View
style={
{
"alignItems": "center",
"flexDirection": "row",
"paddingRight": 60,
}
}
>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
{
"color": "rgba(255,255,255,0.64)",
"fontFamily": "OpenSans-SemiBold",
"fontSize": 11,
"fontWeight": "600",
"lineHeight": 16,
}
}
testID="channel_list_header.server_display_name"
/>
</View>
</View>
<View
accessible={true}
@@ -104,6 +147,7 @@ exports[`components/categories_list should render channels error 1`] = `
"borderRadius": 14,
"height": 28,
"justifyContent": "center",
"marginTop": 7,
"opacity": 1,
"width": 28,
}
@@ -121,30 +165,6 @@ exports[`components/categories_list should render channels error 1`] = `
/>
</View>
</View>
<View
style={
{
"alignItems": "center",
"flexDirection": "row",
"paddingRight": 60,
}
}
>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
{
"color": "rgba(255,255,255,0.64)",
"fontFamily": "OpenSans-SemiBold",
"fontSize": 11,
"fontWeight": "600",
"lineHeight": 16,
}
}
testID="channel_list_header.server_display_name"
/>
</View>
</View>
<View
style={

View File

@@ -19,44 +19,87 @@ exports[`components/channel_list/header Channel List Header Component should mat
<View
style={
{
"alignItems": "center",
"flexDirection": "row",
"justifyContent": "space-between",
}
}
>
<View
accessible={true}
focusable={false}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
{
"alignItems": "center",
"flexDirection": "row",
"justifyContent": "space-between",
"width": "85%",
}
}
>
<Text
<View
style={
{
"color": "#ffffff",
"fontFamily": "Metropolis-SemiBold",
"fontSize": 28,
"fontWeight": "600",
"lineHeight": 36,
"alignItems": "center",
"flexDirection": "row",
"justifyContent": "space-between",
}
}
testID="channel_list_header.team_display_name"
>
Test!
</Text>
<View
accessible={true}
focusable={false}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
{
"alignItems": "center",
"flexDirection": "row",
"justifyContent": "space-between",
}
}
>
<Text
ellipsizeMode="tail"
numberOfLines={2}
style={
{
"color": "#ffffff",
"fontFamily": "Metropolis-SemiBold",
"fontSize": 28,
"fontWeight": "600",
"lineHeight": 36,
}
}
testID="channel_list_header.team_display_name"
>
Test!
</Text>
</View>
</View>
<View
style={
{
"alignItems": "center",
"flexDirection": "row",
"paddingRight": 60,
}
}
>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
{
"color": "rgba(255,255,255,0.64)",
"fontFamily": "OpenSans-SemiBold",
"fontSize": 11,
"fontWeight": "600",
"lineHeight": 16,
}
}
testID="channel_list_header.server_display_name"
/>
</View>
</View>
<View
accessible={true}
@@ -84,6 +127,7 @@ exports[`components/channel_list/header Channel List Header Component should mat
"borderRadius": 14,
"height": 28,
"justifyContent": "center",
"marginTop": 7,
"opacity": 1,
"width": 28,
}
@@ -101,29 +145,5 @@ exports[`components/channel_list/header Channel List Header Component should mat
/>
</View>
</View>
<View
style={
{
"alignItems": "center",
"flexDirection": "row",
"paddingRight": 60,
}
}
>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
{
"color": "rgba(255,255,255,0.64)",
"fontFamily": "OpenSans-SemiBold",
"fontSize": 11,
"fontWeight": "600",
"lineHeight": 16,
}
}
testID="channel_list_header.server_display_name"
/>
</View>
</View>
`;

View File

@@ -26,6 +26,8 @@ import LoadingUnreads from './loading_unreads';
import PlusMenu from './plus_menu';
import {SEPARATOR_HEIGHT} from './plus_menu/separator';
const PLUS_BUTTON_SIZE = 28;
type Props = {
canCreateChannels: boolean;
canJoinChannels: boolean;
@@ -60,9 +62,10 @@ const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({
},
plusButton: {
backgroundColor: changeOpacity(theme.sidebarText, 0.08),
height: 28,
width: 28,
borderRadius: 14,
height: PLUS_BUTTON_SIZE,
width: PLUS_BUTTON_SIZE,
borderRadius: PLUS_BUTTON_SIZE / 2,
marginTop: PLUS_BUTTON_SIZE / 4,
justifyContent: 'center',
alignItems: 'center',
},
@@ -88,6 +91,13 @@ const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({
justifyContent: 'space-between',
height: 40,
},
outsideBox: {
flexDirection: 'row',
justifyContent: 'space-between',
},
firstBox: {
width: '85%', // ratio derived from the design
},
}));
const hitSlop: Insets = {top: 10, bottom: 30, left: 20, right: 20};
@@ -171,59 +181,63 @@ const ChannelListHeader = ({
let header;
if (displayName) {
header = (
<>
<View style={styles.headerRow}>
<TouchableWithoutFeedback
onPress={onHeaderPress}
>
<View style={styles.headerRow}>
<Text
style={styles.headingStyles}
testID='channel_list_header.team_display_name'
>
{displayName}
</Text>
</View>
</TouchableWithoutFeedback>
<TouchableWithFeedback
hitSlop={hitSlop}
onPress={onPress}
style={styles.plusButton}
testID='channel_list_header.plus.button'
type='opacity'
>
<CompassIcon
style={styles.plusIcon}
name={'plus'}
/>
</TouchableWithFeedback>
</View>
<View style={styles.subHeadingView}>
<Text
numberOfLines={1}
ellipsizeMode='tail'
style={styles.subHeadingStyles}
testID='channel_list_header.server_display_name'
>
{serverDisplayName}
</Text>
{(pushProxyStatus !== PUSH_PROXY_STATUS_VERIFIED) && (
<TouchableWithFeedback
onPress={onPushAlertPress}
testID='channel_list_header.push_alert'
type='opacity'
<View style={styles.outsideBox}>
<View style={styles.firstBox}>
<View style={styles.headerRow}>
<TouchableWithoutFeedback
onPress={onHeaderPress}
>
<CompassIcon
name='alert-outline'
color={theme.errorTextColor}
size={14}
style={styles.pushAlert}
/>
</TouchableWithFeedback>
)}
<LoadingUnreads/>
<View style={styles.headerRow}>
<Text
numberOfLines={2}
ellipsizeMode='tail'
style={styles.headingStyles}
testID='channel_list_header.team_display_name'
>
{displayName}
</Text>
</View>
</TouchableWithoutFeedback>
</View>
<View style={styles.subHeadingView}>
<Text
numberOfLines={1}
ellipsizeMode='tail'
style={styles.subHeadingStyles}
testID='channel_list_header.server_display_name'
>
{serverDisplayName}
</Text>
{(pushProxyStatus !== PUSH_PROXY_STATUS_VERIFIED) && (
<TouchableWithFeedback
onPress={onPushAlertPress}
testID='channel_list_header.push_alert'
type='opacity'
>
<CompassIcon
name='alert-outline'
color={theme.errorTextColor}
size={14}
style={styles.pushAlert}
/>
</TouchableWithFeedback>
)}
<LoadingUnreads/>
</View>
</View>
</>
<TouchableWithFeedback
hitSlop={hitSlop}
onPress={onPress}
style={styles.plusButton}
testID='channel_list_header.plus.button'
type='opacity'
>
<CompassIcon
style={styles.plusIcon}
name={'plus'}
/>
</TouchableWithFeedback>
</View>
);
} else {
header = (

View File

@@ -122,7 +122,7 @@ const Servers = React.forwardRef<ServersRef>((_, ref) => {
bottomSheetSnapPoint(Math.min(2.5, registeredServers.current.length), 72, bottom) + TITLE_HEIGHT + BUTTON_HEIGHT,
];
if (registeredServers.current.length > 1) {
snapPoints.push('90%');
snapPoints.push('80%');
}
const closeButtonId = 'close-your-servers';

View File

@@ -4,9 +4,10 @@
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {Tutorial} from '@constants';
import {PUSH_PROXY_STATUS_UNKNOWN} from '@constants/push_proxy';
import DatabaseManager from '@database/manager';
import {observeMultiServerTutorial} from '@queries/app/global';
import {observeTutorialWatched} from '@queries/app/global';
import {observePushVerificationStatus} from '@queries/servers/system';
import ServerItem from './server_item';
@@ -16,7 +17,7 @@ import type ServersModel from '@typings/database/models/app/servers';
const enhance = withObservables(['highlight'], ({highlight, server}: {highlight: boolean; server: ServersModel}) => {
let tutorialWatched = of$(false);
if (highlight) {
tutorialWatched = observeMultiServerTutorial();
tutorialWatched = observeTutorialWatched(Tutorial.MULTI_SERVER);
}
const serverDatabase = DatabaseManager.serverDatabases[server.url]?.database;

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React, {Dispatch, RefObject, SetStateAction, useCallback} from 'react';
import {StyleSheet} from 'react-native';
import {Platform, StyleSheet} from 'react-native';
import OptionItem from '@components/option_item';
import {SearchRef} from '@components/search';
@@ -33,6 +33,12 @@ const Modifier = ({item, searchRef, searchValue, setSearchValue}: Props) => {
addModifierTerm(item.term);
}, [item.term, searchValue]);
const setNativeCursorPositionProp = (position?: number) => {
setTimeout(() => {
searchRef.current?.setNativeProps({selection: {start: position, end: position}});
}, 50);
};
const addModifierTerm = preventDoubleTap((modifierTerm) => {
let newValue = '';
if (!searchValue) {
@@ -46,9 +52,16 @@ const Modifier = ({item, searchRef, searchValue, setSearchValue}: Props) => {
setSearchValue(newValue);
if (item.cursorPosition) {
const position = newValue.length + item.cursorPosition;
setTimeout(() => {
searchRef.current?.setNativeProps({selection: {start: position, end: position}});
}, 50);
setNativeCursorPositionProp(position);
if (Platform.OS === 'android') {
// on Android the selection set by setNativeProps is permanent thus the caret returns to the same
// position after we stop typing for a few ms. By setting the position to undefined,
// then the caret remains in place.
setTimeout(() => {
setNativeCursorPositionProp(undefined);
}, 50);
}
}
});

View File

@@ -42,6 +42,9 @@ const OptionMenus = ({
const handlePermalink = useCallback(() => {
if (fileInfo.post_id) {
if (!isTablet) {
dismissBottomSheet();
}
showPermalink(serverUrl, '', fileInfo.post_id);
setAction('opening');
}

View File

@@ -82,7 +82,7 @@ const TeamPickerIcon = ({size = 24, divider = false, setTeamId, teams, teamId}:
];
if (teams.length > 3) {
snapPoints.push('90%');
snapPoints.push('80%');
}
bottomSheet({

View File

@@ -61,9 +61,6 @@ const SelectedOptions = ({
/>);
});
// eslint-disable-next-line no-warning-comments
// TODO Consider using a Virtualized List since the number of elements is potentially unbounded.
// https://mattermost.atlassian.net/browse/MM-48420
return (
<ScrollView
style={style.container}

View File

@@ -143,6 +143,10 @@ const LoginOptions = ({
return () => navigationEvents.remove();
}, []);
useEffect(() => {
translateX.value = 0;
}, []);
useEffect(() => {
const listener = {
componentDidAppear: () => {

View File

@@ -219,11 +219,7 @@ Appearance.addChangeListener(() => {
});
export function getThemeFromState(): Theme {
if (EphemeralStore.theme) {
return EphemeralStore.theme;
}
return getDefaultThemeByAppearance();
return EphemeralStore.theme || getDefaultThemeByAppearance();
}
// This is a temporary helper function to avoid
@@ -479,12 +475,17 @@ export function goToScreen(name: string, title: string, passProps = {}, options
});
}
export function popTopScreen(screenId?: string) {
if (screenId) {
Navigation.pop(screenId);
} else {
const componentId = NavigationStore.getVisibleScreen();
Navigation.pop(componentId);
export async function popTopScreen(screenId?: string) {
try {
if (screenId) {
await Navigation.pop(screenId);
} else {
const componentId = NavigationStore.getVisibleScreen();
await Navigation.pop(componentId);
}
} catch (error) {
// RNN returns a promise rejection if there are no screens
// atop the root screen to pop. We'll do nothing in this case.
}
}

View File

@@ -84,7 +84,7 @@ const PostOptions = ({
items.push(bottomSheetSnapPoint(optionsCount, ITEM_HEIGHT, bottom) + (canAddReaction ? REACTION_PICKER_HEIGHT + REACTION_PICKER_MARGIN : 0));
if (shouldShowBindings) {
items.push('90%');
items.push('80%');
}
return items;
@@ -103,7 +103,7 @@ const PostOptions = ({
postId={post.id}
/>
}
{canReply && sourceScreen !== Screens.THREAD &&
{canReply &&
<ReplyOption
bottomSheetId={Screens.POST_OPTIONS}
post={post}

View File

@@ -6,7 +6,6 @@ import {useIntl} from 'react-intl';
import {useWindowDimensions, View} from 'react-native';
import {addReaction} from '@actions/remote/reactions';
import CompassIcon from '@components/compass_icon';
import {Screens} from '@constants';
import {
LARGE_CONTAINER_SIZE,
@@ -19,7 +18,7 @@ import {
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {dismissBottomSheet, showModal} from '@screens/navigation';
import {dismissBottomSheet, openAsBottomSheet} from '@screens/navigation';
import {makeStyleSheetFromTheme} from '@utils/theme';
import PickReaction from './pick_reaction';
@@ -61,14 +60,14 @@ const ReactionBar = ({bottomSheetId, recentEmojis = [], postId}: QuickReactionPr
const openEmojiPicker = useCallback(async () => {
await dismissBottomSheet(bottomSheetId);
const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
const screen = Screens.EMOJI_PICKER;
const title = intl.formatMessage({id: 'mobile.post_info.add_reaction', defaultMessage: 'Add Reaction'});
const passProps = {closeButton, onEmojiPress: handleEmojiPress};
showModal(screen, title, passProps);
}, [bottomSheetId, intl, theme]);
openAsBottomSheet({
closeButtonId: 'close-add-reaction',
screen: Screens.EMOJI_PICKER,
theme,
title: intl.formatMessage({id: 'mobile.post_info.add_reaction', defaultMessage: 'Add Reaction'}),
props: {onEmojiPress: handleEmojiPress},
});
}, [handleEmojiPress, intl, theme]);
let containerSize = LARGE_CONTAINER_SIZE;
let iconSize = LARGE_ICON_SIZE;

View File

@@ -85,7 +85,7 @@ const Reactions = ({initialEmoji, location, reactions}: Props) => {
closeButtonId='close-post-reactions'
componentId={Screens.REACTIONS}
initialSnapIndex={1}
snapPoints={[1, '50%', '90%']}
snapPoints={[1, '50%', '80%']}
testID='reactions'
/>
);

View File

@@ -108,7 +108,7 @@ export function getEmojiName(emoji: string, customEmojiNames: string[]) {
if (matchUnicodeEmoji) {
const index = EmojiIndicesByUnicode.get(matchUnicodeEmoji[0]);
if (index != null) {
return fillEmoji(Emojis[index]).name;
return fillEmoji('', Emojis[index]).name;
}
return undefined;
}
@@ -310,11 +310,12 @@ export const isCustomEmojiEnabled = (config: ClientConfig | SystemModel) => {
return config?.EnableCustomEmoji === 'true';
};
export function fillEmoji(index: number) {
export function fillEmoji(category: string, index: number) {
const emoji = Emojis[index];
return {
name: 'short_name' in emoji ? emoji.short_name : emoji.name,
aliases: 'short_names' in emoji ? emoji.short_names : [],
category,
};
}

File diff suppressed because one or more lines are too long

View File

@@ -16,7 +16,6 @@ import {
captureException,
captureJSException,
initializeSentry,
LOGGER_NATIVE,
} from '@utils/sentry';
import {logWarning} from './log';
@@ -31,7 +30,7 @@ class JavascriptAndNativeErrorHandler {
nativeErrorHandler = (e: string) => {
logWarning('Handling native error ' + e);
captureException(e, LOGGER_NATIVE);
captureException(e);
};
errorHandler = (e: Error | ClientError, isFatal: boolean) => {

View File

@@ -18,7 +18,7 @@ import {generateId} from '@utils/general';
import keyMirror from '@utils/key_mirror';
import {logError} from '@utils/log';
import {deleteEntititesFile, getIOSAppGroupDetails} from '@utils/mattermost_managed';
import {hashCode_DEPRECATED, urlSafeBase64Encode} from '@utils/security';
import {urlSafeBase64Encode} from '@utils/security';
import type FileModel from '@typings/database/models/servers/file';
@@ -169,11 +169,6 @@ export async function deleteFileCache(serverUrl: string) {
return deleteFileCacheByDir(serverDir);
}
export async function deleteLegacyFileCache(serverUrl: string) {
const serverDir = hashCode_DEPRECATED(serverUrl);
return deleteFileCacheByDir(serverDir);
}
export async function deleteFileCacheByDir(dir: string) {
if (Platform.OS === 'ios') {
const appGroupCacheDir = `${getIOSAppGroupDetails().appGroupSharedDirectory}/Library/Caches/${dir}`;

View File

@@ -36,12 +36,12 @@ export const getIOSAppGroupDetails = (): IOSAppGroupDetails => {
* e.g :
* MattermostManaged.deleteDatabaseDirectory(databaseName, shouldRemoveDirectory, (error: any, success: any) => { });
*/
export const deleteIOSDatabase = ({
export const deleteIOSDatabase = async ({
databaseName = undefined,
shouldRemoveDirectory = false,
}: IOSDeleteDatabase) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
MattermostManaged.deleteDatabaseDirectory(databaseName, shouldRemoveDirectory, () => null);
return MattermostManaged.deleteDatabaseDirectory(databaseName, shouldRemoveDirectory, () => null);
};
/**

View File

@@ -9,23 +9,6 @@ export async function getCSRFFromCookie(url: string) {
return cookies.MMCSRF?.value;
}
// This has been deprecated and is only used for migrations
export const hashCode_DEPRECATED = (str: string): string => {
let hash = 0;
let i;
let chr;
if (!str || str.length === 0) {
return hash.toString();
}
for (i = 0; i < str.length; i++) {
chr = str.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0; // Convert to 32bit integer
}
return hash.toString();
};
export const urlSafeBase64Encode = (str: string): string => {
return base64.encode(str).replace(/\+/g, '-').replace(/\//g, '_');
};

View File

@@ -17,13 +17,8 @@ import {logError, logWarning} from './log';
export const BREADCRUMB_UNCAUGHT_APP_ERROR = 'uncaught-app-error';
export const BREADCRUMB_UNCAUGHT_NON_ERROR = 'uncaught-non-error';
export const LOGGER_EXTENSION = 'extension';
export const LOGGER_JAVASCRIPT = 'javascript';
export const LOGGER_JAVASCRIPT_WARNING = 'javascript_warning';
export const LOGGER_NATIVE = 'native';
let Sentry: any;
export function initializeSentry() {
if (!Config.SentryEnabled) {
return;
@@ -81,16 +76,16 @@ function getDsn() {
return '';
}
export function captureException(error: Error | string, logger: string) {
export function captureException(error: Error | string) {
if (!Config.SentryEnabled) {
return;
}
if (!error || !logger) {
logWarning('captureException called with missing arguments', error, logger);
if (!error) {
logWarning('captureException called with missing arguments', error);
return;
}
Sentry.captureException(error, {logger});
Sentry.captureException(error);
}
export function captureJSException(error: Error | ClientError, isFatal: boolean) {
@@ -106,7 +101,7 @@ export function captureJSException(error: Error | ClientError, isFatal: boolean)
if (error instanceof ClientError) {
captureClientErrorAsBreadcrumb(error, isFatal);
} else {
captureException(error, LOGGER_JAVASCRIPT);
captureException(error);
}
}

View File

@@ -60,19 +60,9 @@ export function loginOptions(config: ClientConfig, license: ClientLicense) {
const isLicensed = license.IsLicensed === 'true';
const samlEnabled = config.EnableSaml === 'true' && isLicensed && license.SAML === 'true';
const gitlabEnabled = config.EnableSignUpWithGitLab === 'true';
const isMinServerVersionForFreeOAuth = isMinimumServerVersion(config.Version, 7, 6);
let googleEnabled = false;
let o365Enabled = false;
let openIdEnabled = false;
if (isMinServerVersionForFreeOAuth) {
googleEnabled = config.EnableSignUpWithGoogle === 'true';
o365Enabled = config.EnableSignUpWithOffice365 === 'true';
openIdEnabled = config.EnableSignUpWithOpenId === 'true';
} else {
googleEnabled = config.EnableSignUpWithGoogle === 'true' && isLicensed;
o365Enabled = config.EnableSignUpWithOffice365 === 'true' && isLicensed && license.Office365OAuth === 'true';
openIdEnabled = config.EnableSignUpWithOpenId === 'true' && isLicensed;
}
const googleEnabled = config.EnableSignUpWithGoogle === 'true' && isLicensed;
const o365Enabled = config.EnableSignUpWithOffice365 === 'true' && isLicensed && license.Office365OAuth === 'true';
const openIdEnabled = config.EnableSignUpWithOpenId === 'true' && isLicensed;
const ldapEnabled = isLicensed && config.EnableLdap === 'true' && license.LDAP === 'true';
const hasLoginForm = config.EnableSignInWithEmail === 'true' || config.EnableSignInWithUsername === 'true' || ldapEnabled;
const ssoOptions: Record<string, boolean> = {

View File

@@ -1,12 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import deepEqual from 'deep-equal';
import merge from 'deepmerge';
import {StatusBar, StyleSheet} from 'react-native';
import tinyColor from 'tinycolor2';
import {Preferences} from '@constants';
import {MODAL_SCREENS_WITHOUT_BACK, SCREENS_WITH_TRANSPARENT_BACKGROUND} from '@constants/screens';
import {MODAL_SCREENS_WITHOUT_BACK, SCREENS_AS_BOTTOM_SHEET, SCREENS_WITH_TRANSPARENT_BACKGROUND} from '@constants/screens';
import EphemeralStore from '@store/ephemeral_store';
import NavigationStore from '@store/navigation_store';
import {NamedStyles} from '@typings/global/styles';
@@ -99,13 +100,25 @@ export function setNavigatorStyles(componentId: string, theme: Theme, additional
},
};
if (!SCREENS_WITH_TRANSPARENT_BACKGROUND.has(componentId)) {
if (SCREENS_AS_BOTTOM_SHEET.has(componentId)) {
options.topBar = {
leftButtonColor: changeOpacity(theme.centerChannelColor, 0.56),
background: {
color: theme.centerChannelBg,
},
title: {
color: theme.centerChannelColor,
},
};
}
if (!SCREENS_WITH_TRANSPARENT_BACKGROUND.has(componentId) && !SCREENS_AS_BOTTOM_SHEET.has(componentId)) {
options.layout = {
componentBackgroundColor: theme.centerChannelBg,
};
}
if (!MODAL_SCREENS_WITHOUT_BACK.has(componentId) && options.topBar) {
if (!MODAL_SCREENS_WITHOUT_BACK.has(componentId) && !SCREENS_AS_BOTTOM_SHEET.has(componentId) && options.topBar) {
options.topBar.backButton = {
color: theme.sidebarHeaderTextColor,
};
@@ -263,7 +276,8 @@ export function setThemeDefaults(theme: ExtendedTheme): Theme {
}
export const updateThemeIfNeeded = (theme: Theme, force = false) => {
if (theme !== EphemeralStore.theme || force) {
const storedTheme = EphemeralStore.theme;
if (!deepEqual(theme, storedTheme) || force) {
EphemeralStore.theme = theme;
requestAnimationFrame(() => {
setNavigationStackStyles(theme);

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