forked from Ivasoft/mattermost-mobile
Compare commits
53 Commits
MM-45625-d
...
MM-49868_r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0111a59a7 | ||
|
|
eb46a6aeff | ||
|
|
24ec88096c | ||
|
|
82c5244589 | ||
|
|
9c19e4d04a | ||
|
|
2597e06ac2 | ||
|
|
88afe7db91 | ||
|
|
0222504cc9 | ||
|
|
e14d637f3a | ||
|
|
2fadbe15e0 | ||
|
|
d9aa196dfb | ||
|
|
e05e722f64 | ||
|
|
29252e2a84 | ||
|
|
4ee169e458 | ||
|
|
6cb4dc4cc0 | ||
|
|
a8ef1a5bed | ||
|
|
c5fa6d0827 | ||
|
|
2ebb34266f | ||
|
|
79f665f8eb | ||
|
|
a284770861 | ||
|
|
363313e530 | ||
|
|
1eb5c958ec | ||
|
|
cd6ae1692d | ||
|
|
c0edb4ecd4 | ||
|
|
d417b95643 | ||
|
|
49fc180982 | ||
|
|
b191154db9 | ||
|
|
612fd5022f | ||
|
|
9411dbd669 | ||
|
|
c8ded6ef3c | ||
|
|
bb351c7376 | ||
|
|
247d8371d9 | ||
|
|
cab863d62f | ||
|
|
5125db1d69 | ||
|
|
0380382b77 | ||
|
|
a10357e03c | ||
|
|
4199b13843 | ||
|
|
8edf128d59 | ||
|
|
6082a6a790 | ||
|
|
5ea1965ae7 | ||
|
|
1362d4a90b | ||
|
|
e6a6e25799 | ||
|
|
252dc1e3c8 | ||
|
|
69e27ae812 | ||
|
|
b653b86443 | ||
|
|
107416109a | ||
|
|
c1abccf3ed | ||
|
|
977bcd2b42 | ||
|
|
68ec90df90 | ||
|
|
c8d9758f18 | ||
|
|
8097ee300d | ||
|
|
c2aaef2e14 | ||
|
|
d465edefc3 |
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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'},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -85,7 +85,7 @@ const MarkdownCodeBlock = ({language = '', content, textStyle}: MarkdownCodeBloc
|
||||
const screen = Screens.CODE;
|
||||
const passProps = {
|
||||
code: content,
|
||||
language,
|
||||
language: getHighlightLanguageFromNameOrAlias(language),
|
||||
textStyle,
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
61
app/components/touchable_emoji/index.tsx
Normal file
61
app/components/touchable_emoji/index.tsx
Normal 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);
|
||||
34
app/components/touchable_emoji/skinned_emoji.tsx
Normal file
34
app/components/touchable_emoji/skinned_emoji.tsx
Normal 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);
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
12
app/constants/tutorial.ts
Normal 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,
|
||||
};
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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: []});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
} from './table_schemas';
|
||||
|
||||
export const serverSchema: AppSchema = appSchema({
|
||||
version: 7,
|
||||
version: 1,
|
||||
tables: [
|
||||
CategorySchema,
|
||||
CategoryChannelSchema,
|
||||
|
||||
@@ -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]: {
|
||||
|
||||
30
app/helpers/database/upgrade.ts
Normal file
30
app/helpers/database/upgrade.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
93
app/hooks/emoji_category_bar.ts
Normal file
93
app/hooks/emoji_category_bar.ts
Normal 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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
24
app/queries/app/info.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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))),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
67
app/screens/emoji_picker/picker/emoji_category_bar/index.tsx
Normal file
67
app/screens/emoji_picker/picker/emoji_category_bar/index.tsx
Normal 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;
|
||||
@@ -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'
|
||||
23
app/screens/emoji_picker/picker/filtered/index.ts
Normal file
23
app/screens/emoji_picker/picker/filtered/index.ts
Normal 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));
|
||||
69
app/screens/emoji_picker/picker/footer/index.tsx
Normal file
69
app/screens/emoji_picker/picker/footer/index.tsx
Normal 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;
|
||||
@@ -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;
|
||||
85
app/screens/emoji_picker/picker/header/header.tsx
Normal file
85
app/screens/emoji_picker/picker/header/header.tsx
Normal 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;
|
||||
23
app/screens/emoji_picker/picker/header/index.ts
Normal file
23
app/screens/emoji_picker/picker/header/index.ts
Normal 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));
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
20
app/screens/emoji_picker/picker/index.ts
Normal file
20
app/screens/emoji_picker/picker/index.ts
Normal 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));
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -42,6 +42,9 @@ const OptionMenus = ({
|
||||
|
||||
const handlePermalink = useCallback(() => {
|
||||
if (fileInfo.post_id) {
|
||||
if (!isTablet) {
|
||||
dismissBottomSheet();
|
||||
}
|
||||
showPermalink(serverUrl, '', fileInfo.post_id);
|
||||
setAction('opening');
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ const TeamPickerIcon = ({size = 24, divider = false, setTeamId, teams, teamId}:
|
||||
];
|
||||
|
||||
if (teams.length > 3) {
|
||||
snapPoints.push('90%');
|
||||
snapPoints.push('80%');
|
||||
}
|
||||
|
||||
bottomSheet({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -143,6 +143,10 @@ const LoginOptions = ({
|
||||
return () => navigationEvents.remove();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
translateX.value = 0;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = {
|
||||
componentDidAppear: () => {
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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, '_');
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user