Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
850a3d5d2a | ||
|
|
78c01a74f7 | ||
|
|
1594d5e77f | ||
|
|
38d3c7d1d1 | ||
|
|
e347e0a96c | ||
|
|
272895d37f |
49 changed files with 1832 additions and 299 deletions
34
NOTICE.txt
34
NOTICE.txt
|
|
@ -2399,6 +2399,40 @@ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## react-native-performance
|
||||||
|
|
||||||
|
This product contains 'react-native-performance' by Joel Arvidsson.
|
||||||
|
|
||||||
|
Toolchain to measure and monitor the performance of your React Native app in development, pipeline and in production.
|
||||||
|
|
||||||
|
* HOMEPAGE:
|
||||||
|
* https://github.com/oblador/react-native-performance
|
||||||
|
|
||||||
|
* LICENSE: MIT
|
||||||
|
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2019 - present Joel Arvidsson
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,20 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.camera"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<uses-permission-sdk-23 android:name="android.permission.VIBRATE"/>
|
<uses-permission-sdk-23 android:name="android.permission.VIBRATE"/>
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
<uses-permission android:name="android.permission.CAMERA"/>
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:ignore="SelectedPhotoAccess" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:ignore="SelectedPhotoAccess" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:ignore="ScopedStorage" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:ignore="SelectedPhotoAccess" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
|
@ -34,6 +40,8 @@
|
||||||
<application
|
<application
|
||||||
android:name=".MainApplication"
|
android:name=".MainApplication"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="false"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
|
@ -49,7 +57,6 @@
|
||||||
android:resource="@xml/app_restrictions" />
|
android:resource="@xml/app_restrictions" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:label="@string/app_name"
|
|
||||||
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
|
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
|
|
@ -84,14 +91,12 @@
|
||||||
android:name="com.reactnativenavigation.controllers.NavigationActivity"
|
android:name="com.reactnativenavigation.controllers.NavigationActivity"
|
||||||
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
|
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
|
||||||
android:resizeableActivity="true"
|
android:resizeableActivity="true"
|
||||||
android:exported="true"
|
android:exported="false"
|
||||||
/>
|
/>
|
||||||
<activity
|
<activity
|
||||||
android:name="com.mattermost.share.ShareActivity"
|
android:name="com.mattermost.share.ShareActivity"
|
||||||
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
|
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:label="@string/app_name"
|
|
||||||
android:screenOrientation="portrait"
|
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
android:taskAffinity="com.mattermost.share"
|
android:taskAffinity="com.mattermost.share"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|
@ -109,7 +114,7 @@
|
||||||
<service
|
<service
|
||||||
android:name="com.voximplant.foregroundservice.VIForegroundService"
|
android:name="com.voximplant.foregroundservice.VIForegroundService"
|
||||||
android:foregroundServiceType="microphone"
|
android:foregroundServiceType="microphone"
|
||||||
android:exported="true"
|
android:exported="false"
|
||||||
/>
|
/>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -4,29 +4,27 @@ import android.content.Context
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.facebook.react.bridge.Arguments
|
import com.facebook.react.bridge.Arguments
|
||||||
|
|
||||||
import com.facebook.react.bridge.ReadableArray
|
import com.facebook.react.bridge.ReadableArray
|
||||||
import com.facebook.react.bridge.ReadableMap
|
import com.facebook.react.bridge.ReadableMap
|
||||||
|
import com.mattermost.helpers.database_extension.getDatabaseForServer
|
||||||
import com.mattermost.helpers.database_extension.*
|
import com.mattermost.helpers.database_extension.saveToDatabase
|
||||||
import com.mattermost.helpers.push_notification.*
|
import com.mattermost.helpers.push_notification.addToDefaultCategoryIfNeeded
|
||||||
|
import com.mattermost.helpers.push_notification.fetchMyChannel
|
||||||
import kotlinx.coroutines.*
|
import com.mattermost.helpers.push_notification.fetchMyTeamCategories
|
||||||
|
import com.mattermost.helpers.push_notification.fetchNeededUsers
|
||||||
|
import com.mattermost.helpers.push_notification.fetchPosts
|
||||||
|
import com.mattermost.helpers.push_notification.fetchTeamIfNeeded
|
||||||
|
import com.mattermost.helpers.push_notification.fetchThread
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
class PushNotificationDataHelper(private val context: Context) {
|
class PushNotificationDataHelper(private val context: Context) {
|
||||||
private var coroutineScope = CoroutineScope(Dispatchers.Default)
|
suspend fun fetchAndStoreDataForPushNotification(initialData: Bundle, isReactInit: Boolean): Bundle? {
|
||||||
fun fetchAndStoreDataForPushNotification(initialData: Bundle, isReactInit: Boolean): Bundle? {
|
return withContext(Dispatchers.Default) {
|
||||||
var result: Bundle? = null
|
PushNotificationDataRunnable.start(context, initialData, isReactInit)
|
||||||
val job = coroutineScope.launch(Dispatchers.Default) {
|
|
||||||
result = PushNotificationDataRunnable.start(context, initialData, isReactInit)
|
|
||||||
}
|
}
|
||||||
runBlocking {
|
|
||||||
job.join()
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,8 +35,8 @@ class PushNotificationDataRunnable {
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
suspend fun start(context: Context, initialData: Bundle, isReactInit: Boolean): Bundle? {
|
suspend fun start(context: Context, initialData: Bundle, isReactInit: Boolean): Bundle? {
|
||||||
// for more info see: https://blog.danlew.net/2020/01/28/coroutines-and-java-synchronization-dont-mix/
|
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
|
// for more info see: https://blog.danlew.net/2020/01/28/coroutines-and-java-synchronization-dont-mix/
|
||||||
val serverUrl: String = initialData.getString("server_url") ?: return null
|
val serverUrl: String = initialData.getString("server_url") ?: return null
|
||||||
val db = dbHelper.getDatabaseForServer(context, serverUrl)
|
val db = dbHelper.getDatabaseForServer(context, serverUrl)
|
||||||
var result: Bundle? = null
|
var result: Bundle? = null
|
||||||
|
|
@ -50,8 +48,9 @@ class PushNotificationDataRunnable {
|
||||||
val postId = initialData.getString("post_id")
|
val postId = initialData.getString("post_id")
|
||||||
val rootId = initialData.getString("root_id")
|
val rootId = initialData.getString("root_id")
|
||||||
val isCRTEnabled = initialData.getString("is_crt_enabled") == "true"
|
val isCRTEnabled = initialData.getString("is_crt_enabled") == "true"
|
||||||
|
val ackId = initialData.getString("ack_id")
|
||||||
|
|
||||||
Log.i("ReactNative", "Start fetching notification data in server=$serverUrl for channel=$channelId")
|
Log.i("ReactNative", "Start fetching notification data in server=$serverUrl for channel=$channelId and ack=$ackId")
|
||||||
|
|
||||||
val receivingThreads = isCRTEnabled && !rootId.isNullOrEmpty()
|
val receivingThreads = isCRTEnabled && !rootId.isNullOrEmpty()
|
||||||
val notificationData = Arguments.createMap()
|
val notificationData = Arguments.createMap()
|
||||||
|
|
@ -89,7 +88,7 @@ class PushNotificationDataRunnable {
|
||||||
|
|
||||||
getThreadList(notificationThread, postData?.getArray("threads"))?.let {
|
getThreadList(notificationThread, postData?.getArray("threads"))?.let {
|
||||||
val threadsArray = Arguments.createArray()
|
val threadsArray = Arguments.createArray()
|
||||||
for(item in it) {
|
for (item in it) {
|
||||||
threadsArray.pushMap(item)
|
threadsArray.pushMap(item)
|
||||||
}
|
}
|
||||||
notificationData.putArray("threads", threadsArray)
|
notificationData.putArray("threads", threadsArray)
|
||||||
|
|
@ -105,7 +104,7 @@ class PushNotificationDataRunnable {
|
||||||
dbHelper.saveToDatabase(db, notificationData, teamId, channelId, receivingThreads)
|
dbHelper.saveToDatabase(db, notificationData, teamId, channelId, receivingThreads)
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.i("ReactNative", "Done processing push notification=$serverUrl for channel=$channelId")
|
Log.i("ReactNative", "Done processing push notification=$serverUrl for channel=$channelId and ack=$ackId")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.mattermost.helpers.database_extension
|
package com.mattermost.helpers.database_extension
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import com.facebook.react.bridge.Arguments
|
import com.facebook.react.bridge.Arguments
|
||||||
import com.facebook.react.bridge.ReadableMap
|
import com.facebook.react.bridge.ReadableMap
|
||||||
|
|
@ -8,8 +9,7 @@ import com.mattermost.helpers.DatabaseHelper
|
||||||
import com.mattermost.helpers.QueryArgs
|
import com.mattermost.helpers.QueryArgs
|
||||||
import com.mattermost.helpers.mapCursor
|
import com.mattermost.helpers.mapCursor
|
||||||
import com.nozbe.watermelondb.WMDatabase
|
import com.nozbe.watermelondb.WMDatabase
|
||||||
import java.util.*
|
import java.util.Arrays
|
||||||
import kotlin.Exception
|
|
||||||
|
|
||||||
internal fun DatabaseHelper.saveToDatabase(db: WMDatabase, data: ReadableMap, teamId: String?, channelId: String?, receivingThreads: Boolean) {
|
internal fun DatabaseHelper.saveToDatabase(db: WMDatabase, data: ReadableMap, teamId: String?, channelId: String?, receivingThreads: Boolean) {
|
||||||
db.transaction {
|
db.transaction {
|
||||||
|
|
@ -57,7 +57,7 @@ fun DatabaseHelper.getDatabaseForServer(context: Context?, serverUrl: String): W
|
||||||
if (cursor.count == 1) {
|
if (cursor.count == 1) {
|
||||||
cursor.moveToFirst()
|
cursor.moveToFirst()
|
||||||
val databasePath = String.format("file://%s", cursor.getString(0))
|
val databasePath = String.format("file://%s", cursor.getString(0))
|
||||||
return WMDatabase.getInstance(databasePath, context!!)
|
return WMDatabase.buildDatabase(databasePath, context!!, SQLiteDatabase.CREATE_IF_NECESSARY)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
@ -73,7 +73,7 @@ fun DatabaseHelper.getDeviceToken(): String? {
|
||||||
defaultDatabase!!.rawQuery(query, arrayOf("deviceToken")).use { cursor ->
|
defaultDatabase!!.rawQuery(query, arrayOf("deviceToken")).use { cursor ->
|
||||||
if (cursor.count == 1) {
|
if (cursor.count == 1) {
|
||||||
cursor.moveToFirst()
|
cursor.moveToFirst()
|
||||||
return cursor.getString(0);
|
return cursor.getString(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
package com.mattermost.rnbeta;
|
|
||||||
|
|
||||||
import android.app.Notification;
|
|
||||||
import android.app.PendingIntent;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.text.TextUtils;
|
|
||||||
import android.util.Log;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.core.app.NotificationCompat;
|
|
||||||
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
import com.facebook.react.bridge.ReadableMap;
|
|
||||||
import com.mattermost.helpers.CustomPushNotificationHelper;
|
|
||||||
import com.mattermost.helpers.DatabaseHelper;
|
|
||||||
import com.mattermost.helpers.Network;
|
|
||||||
import com.mattermost.helpers.NotificationHelper;
|
|
||||||
import com.mattermost.helpers.PushNotificationDataHelper;
|
|
||||||
import com.mattermost.helpers.ReadableMapUtils;
|
|
||||||
import com.mattermost.share.ShareModule;
|
|
||||||
import com.wix.reactnativenotifications.core.NotificationIntentAdapter;
|
|
||||||
import com.wix.reactnativenotifications.core.notification.PushNotification;
|
|
||||||
import com.wix.reactnativenotifications.core.AppLaunchHelper;
|
|
||||||
import com.wix.reactnativenotifications.core.AppLifecycleFacade;
|
|
||||||
import com.wix.reactnativenotifications.core.JsIOHelper;
|
|
||||||
|
|
||||||
import static com.mattermost.helpers.database_extension.GeneralKt.*;
|
|
||||||
import static com.wix.reactnativenotifications.Defs.NOTIFICATION_RECEIVED_EVENT_NAME;
|
|
||||||
|
|
||||||
|
|
||||||
public class CustomPushNotification extends PushNotification {
|
|
||||||
private final PushNotificationDataHelper dataHelper;
|
|
||||||
|
|
||||||
public CustomPushNotification(Context context, Bundle bundle, AppLifecycleFacade appLifecycleFacade, AppLaunchHelper appLaunchHelper, JsIOHelper jsIoHelper) {
|
|
||||||
super(context, bundle, appLifecycleFacade, appLaunchHelper, jsIoHelper);
|
|
||||||
dataHelper = new PushNotificationDataHelper(context);
|
|
||||||
|
|
||||||
try {
|
|
||||||
Objects.requireNonNull(DatabaseHelper.Companion.getInstance()).init(context);
|
|
||||||
Network.init(context);
|
|
||||||
NotificationHelper.cleanNotificationPreferencesIfNeeded(context);
|
|
||||||
} catch (Exception e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onReceived() {
|
|
||||||
final Bundle initialData = mNotificationProps.asBundle();
|
|
||||||
final String type = initialData.getString("type");
|
|
||||||
final String ackId = initialData.getString("ack_id");
|
|
||||||
final String postId = initialData.getString("post_id");
|
|
||||||
final String channelId = initialData.getString("channel_id");
|
|
||||||
final String signature = initialData.getString("signature");
|
|
||||||
final boolean isIdLoaded = initialData.getString("id_loaded") != null && initialData.getString("id_loaded").equals("true");
|
|
||||||
int notificationId = NotificationHelper.getNotificationId(initialData);
|
|
||||||
|
|
||||||
String serverUrl = addServerUrlToBundle(initialData);
|
|
||||||
|
|
||||||
if (ackId != null && serverUrl != null) {
|
|
||||||
Bundle response = ReceiptDelivery.send(ackId, serverUrl, postId, type, isIdLoaded);
|
|
||||||
if (isIdLoaded && response != null) {
|
|
||||||
Bundle current = mNotificationProps.asBundle();
|
|
||||||
if (!current.containsKey("server_url")) {
|
|
||||||
response.putString("server_url", serverUrl);
|
|
||||||
}
|
|
||||||
current.putAll(response);
|
|
||||||
mNotificationProps = createProps(current);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!CustomPushNotificationHelper.verifySignature(mContext, signature, serverUrl, ackId)) {
|
|
||||||
Log.i("Mattermost Notifications Signature verification", "Notification skipped because we could not verify it.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
finishProcessingNotification(serverUrl, type, channelId, notificationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onOpened() {
|
|
||||||
if (mNotificationProps != null) {
|
|
||||||
digestNotification();
|
|
||||||
|
|
||||||
Bundle data = mNotificationProps.asBundle();
|
|
||||||
NotificationHelper.clearChannelOrThreadNotifications(mContext, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void finishProcessingNotification(final String serverUrl, @NonNull final String type, final String channelId, final int notificationId) {
|
|
||||||
final boolean isReactInit = mAppLifecycleFacade.isReactInitialized();
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case CustomPushNotificationHelper.PUSH_TYPE_MESSAGE:
|
|
||||||
case CustomPushNotificationHelper.PUSH_TYPE_SESSION:
|
|
||||||
ShareModule shareModule = ShareModule.getInstance();
|
|
||||||
String currentActivityName = shareModule != null ? shareModule.getCurrentActivityName() : "";
|
|
||||||
Log.i("ReactNative", currentActivityName);
|
|
||||||
if (!mAppLifecycleFacade.isAppVisible() || !currentActivityName.equals("MainActivity")) {
|
|
||||||
boolean createSummary = type.equals(CustomPushNotificationHelper.PUSH_TYPE_MESSAGE);
|
|
||||||
if (type.equals(CustomPushNotificationHelper.PUSH_TYPE_MESSAGE)) {
|
|
||||||
if (channelId != null) {
|
|
||||||
Bundle notificationBundle = mNotificationProps.asBundle();
|
|
||||||
if (serverUrl != null) {
|
|
||||||
// We will only fetch the data related to the notification on the native side
|
|
||||||
// as updating the data directly to the db removes the wal & shm files needed
|
|
||||||
// by watermelonDB, if the DB is updated while WDB is running it causes WDB to
|
|
||||||
// detect the database as malformed, thus the app stop working and a restart is required.
|
|
||||||
// Data will be fetch from within the JS context instead.
|
|
||||||
Bundle notificationResult = dataHelper.fetchAndStoreDataForPushNotification(notificationBundle, isReactInit);
|
|
||||||
if (notificationResult != null) {
|
|
||||||
notificationBundle.putBundle("data", notificationResult);
|
|
||||||
mNotificationProps = createProps(notificationBundle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
createSummary = NotificationHelper.addNotificationToPreferences(
|
|
||||||
mContext,
|
|
||||||
notificationId,
|
|
||||||
notificationBundle
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildNotification(notificationId, createSummary);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case CustomPushNotificationHelper.PUSH_TYPE_CLEAR:
|
|
||||||
NotificationHelper.clearChannelOrThreadNotifications(mContext, mNotificationProps.asBundle());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isReactInit) {
|
|
||||||
notifyReceivedToJS();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void buildNotification(Integer notificationId, boolean createSummary) {
|
|
||||||
final PendingIntent pendingIntent = NotificationIntentAdapter.createPendingNotificationIntent(mContext, mNotificationProps);
|
|
||||||
final Notification notification = buildNotification(pendingIntent);
|
|
||||||
if (createSummary) {
|
|
||||||
final Notification summary = getNotificationSummaryBuilder(pendingIntent).build();
|
|
||||||
super.postNotification(summary, notificationId + 1);
|
|
||||||
}
|
|
||||||
super.postNotification(notification, notificationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected NotificationCompat.Builder getNotificationBuilder(PendingIntent intent) {
|
|
||||||
Bundle bundle = mNotificationProps.asBundle();
|
|
||||||
return CustomPushNotificationHelper.createNotificationBuilder(mContext, intent, bundle, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected NotificationCompat.Builder getNotificationSummaryBuilder(PendingIntent intent) {
|
|
||||||
Bundle bundle = mNotificationProps.asBundle();
|
|
||||||
return CustomPushNotificationHelper.createNotificationBuilder(mContext, intent, bundle, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void notifyReceivedToJS() {
|
|
||||||
mJsIOHelper.sendEventToJS(NOTIFICATION_RECEIVED_EVENT_NAME, mNotificationProps.asBundle(), mAppLifecycleFacade.getRunningReactContext());
|
|
||||||
}
|
|
||||||
|
|
||||||
private String addServerUrlToBundle(Bundle bundle) {
|
|
||||||
DatabaseHelper dbHelper = DatabaseHelper.Companion.getInstance();
|
|
||||||
String serverId = bundle.getString("server_id");
|
|
||||||
String serverUrl = null;
|
|
||||||
if (dbHelper != null) {
|
|
||||||
if (serverId == null) {
|
|
||||||
serverUrl = dbHelper.getOnlyServerUrl();
|
|
||||||
} else {
|
|
||||||
serverUrl = getServerUrlForIdentifier(dbHelper, serverId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!TextUtils.isEmpty(serverUrl)) {
|
|
||||||
bundle.putString("server_url", serverUrl);
|
|
||||||
mNotificationProps = createProps(bundle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return serverUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
package com.mattermost.rnbeta
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.mattermost.helpers.CustomPushNotificationHelper
|
||||||
|
import com.mattermost.helpers.DatabaseHelper
|
||||||
|
import com.mattermost.helpers.Network
|
||||||
|
import com.mattermost.helpers.NotificationHelper
|
||||||
|
import com.mattermost.helpers.PushNotificationDataHelper
|
||||||
|
import com.mattermost.helpers.database_extension.getServerUrlForIdentifier
|
||||||
|
import com.mattermost.share.ShareModule
|
||||||
|
import com.wix.reactnativenotifications.Defs.NOTIFICATION_RECEIVED_EVENT_NAME
|
||||||
|
import com.wix.reactnativenotifications.core.AppLaunchHelper
|
||||||
|
import com.wix.reactnativenotifications.core.AppLifecycleFacade
|
||||||
|
import com.wix.reactnativenotifications.core.JsIOHelper
|
||||||
|
import com.wix.reactnativenotifications.core.NotificationIntentAdapter
|
||||||
|
import com.wix.reactnativenotifications.core.notification.PushNotification
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class CustomPushNotification(
|
||||||
|
context: Context,
|
||||||
|
bundle: Bundle,
|
||||||
|
appLifecycleFacade: AppLifecycleFacade,
|
||||||
|
appLaunchHelper: AppLaunchHelper,
|
||||||
|
jsIoHelper: JsIOHelper
|
||||||
|
) : PushNotification(context, bundle, appLifecycleFacade, appLaunchHelper, jsIoHelper) {
|
||||||
|
private val dataHelper = PushNotificationDataHelper(context)
|
||||||
|
|
||||||
|
init {
|
||||||
|
try {
|
||||||
|
DatabaseHelper.instance?.init(context)
|
||||||
|
Network.init(context)
|
||||||
|
NotificationHelper.cleanNotificationPreferencesIfNeeded(context)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
override fun onReceived() {
|
||||||
|
val initialData = mNotificationProps.asBundle()
|
||||||
|
val type = initialData.getString("type")
|
||||||
|
val ackId = initialData.getString("ack_id")
|
||||||
|
val postId = initialData.getString("post_id")
|
||||||
|
val channelId = initialData.getString("channel_id")
|
||||||
|
val signature = initialData.getString("signature")
|
||||||
|
val isIdLoaded = initialData.getString("id_loaded") == "true"
|
||||||
|
val notificationId = NotificationHelper.getNotificationId(initialData)
|
||||||
|
val serverUrl = addServerUrlToBundle(initialData)
|
||||||
|
|
||||||
|
GlobalScope.launch {
|
||||||
|
try {
|
||||||
|
handlePushNotificationInCoroutine(serverUrl, type, channelId, ackId, isIdLoaded, notificationId, postId, signature)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handlePushNotificationInCoroutine(
|
||||||
|
serverUrl: String?,
|
||||||
|
type: String?,
|
||||||
|
channelId: String?,
|
||||||
|
ackId: String?,
|
||||||
|
isIdLoaded: Boolean,
|
||||||
|
notificationId: Int,
|
||||||
|
postId: String?,
|
||||||
|
signature: String?
|
||||||
|
) {
|
||||||
|
if (ackId != null && serverUrl != null) {
|
||||||
|
val response = ReceiptDelivery.send(ackId, serverUrl, postId, type, isIdLoaded)
|
||||||
|
if (isIdLoaded && response != null) {
|
||||||
|
val current = mNotificationProps.asBundle()
|
||||||
|
if (!current.containsKey("server_url")) {
|
||||||
|
response.putString("server_url", serverUrl)
|
||||||
|
}
|
||||||
|
current.putAll(response)
|
||||||
|
mNotificationProps = createProps(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CustomPushNotificationHelper.verifySignature(mContext, signature, serverUrl, ackId)) {
|
||||||
|
Log.i("Mattermost Notifications Signature verification", "Notification skipped because we could not verify it.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
finishProcessingNotification(serverUrl, type, channelId, notificationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOpened() {
|
||||||
|
mNotificationProps?.let {
|
||||||
|
digestNotification()
|
||||||
|
NotificationHelper.clearChannelOrThreadNotifications(mContext, it.asBundle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun finishProcessingNotification(serverUrl: String?, type: String?, channelId: String?, notificationId: Int) {
|
||||||
|
val isReactInit = mAppLifecycleFacade.isReactInitialized()
|
||||||
|
|
||||||
|
when (type) {
|
||||||
|
CustomPushNotificationHelper.PUSH_TYPE_MESSAGE, CustomPushNotificationHelper.PUSH_TYPE_SESSION -> {
|
||||||
|
val shareModule = ShareModule.getInstance()
|
||||||
|
val currentActivityName = shareModule?.currentActivityName ?: ""
|
||||||
|
Log.i("ReactNative", currentActivityName)
|
||||||
|
if (!mAppLifecycleFacade.isAppVisible() || currentActivityName != "MainActivity") {
|
||||||
|
var createSummary = type == CustomPushNotificationHelper.PUSH_TYPE_MESSAGE
|
||||||
|
if (type == CustomPushNotificationHelper.PUSH_TYPE_MESSAGE) {
|
||||||
|
channelId?.let {
|
||||||
|
val notificationBundle = mNotificationProps.asBundle()
|
||||||
|
serverUrl?.let {
|
||||||
|
val notificationResult = dataHelper.fetchAndStoreDataForPushNotification(notificationBundle, isReactInit)
|
||||||
|
notificationResult?.let { result ->
|
||||||
|
notificationBundle.putBundle("data", result)
|
||||||
|
mNotificationProps = createProps(notificationBundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createSummary = NotificationHelper.addNotificationToPreferences(mContext, notificationId, notificationBundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildNotification(notificationId, createSummary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CustomPushNotificationHelper.PUSH_TYPE_CLEAR -> NotificationHelper.clearChannelOrThreadNotifications(mContext, mNotificationProps.asBundle())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReactInit) {
|
||||||
|
notifyReceivedToJS()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(notificationId: Int, createSummary: Boolean) {
|
||||||
|
val pendingIntent = NotificationIntentAdapter.createPendingNotificationIntent(mContext, mNotificationProps)
|
||||||
|
val notification = buildNotification(pendingIntent)
|
||||||
|
if (createSummary) {
|
||||||
|
val summary = getNotificationSummaryBuilder(pendingIntent).build()
|
||||||
|
super.postNotification(summary, notificationId + 1)
|
||||||
|
}
|
||||||
|
super.postNotification(notification, notificationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getNotificationBuilder(intent: PendingIntent): NotificationCompat.Builder {
|
||||||
|
val bundle = mNotificationProps.asBundle()
|
||||||
|
return CustomPushNotificationHelper.createNotificationBuilder(mContext, intent, bundle, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getNotificationSummaryBuilder(intent: PendingIntent): NotificationCompat.Builder {
|
||||||
|
val bundle = mNotificationProps.asBundle()
|
||||||
|
return CustomPushNotificationHelper.createNotificationBuilder(mContext, intent, bundle, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyReceivedToJS() {
|
||||||
|
mJsIOHelper.sendEventToJS(NOTIFICATION_RECEIVED_EVENT_NAME, mNotificationProps.asBundle(), mAppLifecycleFacade.runningReactContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addServerUrlToBundle(bundle: Bundle): String? {
|
||||||
|
val dbHelper = DatabaseHelper.instance
|
||||||
|
val serverId = bundle.getString("server_id")
|
||||||
|
var serverUrl: String? = null
|
||||||
|
|
||||||
|
dbHelper?.let {
|
||||||
|
serverUrl = if (serverId == null) {
|
||||||
|
it.onlyServerUrl
|
||||||
|
} else {
|
||||||
|
it.getServerUrlForIdentifier(serverId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!serverUrl.isNullOrEmpty()) {
|
||||||
|
bundle.putString("server_url", serverUrl)
|
||||||
|
mNotificationProps = createProps(bundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return serverUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -173,10 +173,10 @@ class MainApplication : NavigationApplication(), INotificationsApplication {
|
||||||
defaultAppLaunchHelper: AppLaunchHelper?
|
defaultAppLaunchHelper: AppLaunchHelper?
|
||||||
): IPushNotification {
|
): IPushNotification {
|
||||||
return CustomPushNotification(
|
return CustomPushNotification(
|
||||||
context,
|
context!!,
|
||||||
bundle,
|
bundle!!,
|
||||||
defaultFacade,
|
defaultFacade!!,
|
||||||
defaultAppLaunchHelper,
|
defaultAppLaunchHelper!!,
|
||||||
JsIOHelper()
|
JsIOHelper()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
android/app/src/main/res/xml/data_extraction_rules.xml
Normal file
13
android/app/src/main/res/xml/data_extraction_rules.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<exclude domain="sharedpref"/>
|
||||||
|
<exclude domain="file" path="./databases"/>
|
||||||
|
</cloud-backup>
|
||||||
|
<!--
|
||||||
|
<device-transfer>
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
</device-transfer>
|
||||||
|
-->
|
||||||
|
</data-extraction-rules>
|
||||||
201
app/actions/remote/entry/notification.test.ts
Normal file
201
app/actions/remote/entry/notification.test.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {ActionType} from '@constants';
|
||||||
|
import DatabaseManager from '@database/manager';
|
||||||
|
import NetworkManager from '@managers/network_manager';
|
||||||
|
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
|
||||||
|
import {prepareThreadsFromReceivedPosts} from '@queries/servers/thread';
|
||||||
|
import NavigationStore from '@store/navigation_store';
|
||||||
|
import {mockApiClient} from '@test/mock_api_client';
|
||||||
|
import TestHelper from '@test/test_helper';
|
||||||
|
|
||||||
|
import {pushNotificationEntry} from './notification';
|
||||||
|
|
||||||
|
import type ServerDataOperator from '@database/operator/server_data_operator';
|
||||||
|
|
||||||
|
jest.mock('@managers/performance_metrics_manager');
|
||||||
|
jest.mock('@store/navigation_store');
|
||||||
|
|
||||||
|
const mockedNavigationStore = jest.mocked(NavigationStore);
|
||||||
|
|
||||||
|
describe('Performance metrics are set correctly', () => {
|
||||||
|
const serverUrl = 'http://www.someserverurl.com';
|
||||||
|
let operator: ServerDataOperator;
|
||||||
|
let post: Post;
|
||||||
|
beforeAll(() => {
|
||||||
|
mockApiClient.get.mockImplementation((url: string) => {
|
||||||
|
if (url.match(/\/api\/v4\/channels\/[a-z1-90-]*\/posts/)) {
|
||||||
|
return {status: 200, ok: true, data: {order: [], posts: {}}};
|
||||||
|
}
|
||||||
|
if (url.match(/\/api\/v4\/channels\/[a-z1-90-]*\/stats/)) {
|
||||||
|
return {status: 200, ok: true, data: {}};
|
||||||
|
}
|
||||||
|
if (url.match(/\/api\/v4\/posts\/[a-z1-90-]*\/thread/)) {
|
||||||
|
return {status: 200, ok: true, data: {order: [], posts: {}}};
|
||||||
|
}
|
||||||
|
console.log(`GET ${url} not registered in the mock`);
|
||||||
|
return {status: 404, ok: false};
|
||||||
|
});
|
||||||
|
|
||||||
|
mockApiClient.post.mockImplementation((url: string) => {
|
||||||
|
if (url.match(/\/api\/v4\/channels\/members\/me\/view/)) {
|
||||||
|
return {status: 200, ok: true, data: {}};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`POST ${url} not registered in the mock`);
|
||||||
|
return {status: 404, ok: false};
|
||||||
|
});
|
||||||
|
mockedNavigationStore.waitUntilScreenIsTop.mockImplementation(() => Promise.resolve());
|
||||||
|
|
||||||
|
// There are no problems when running the tests for this file alone without this line
|
||||||
|
// but for some reason, when running several tests together, it fails if we don't add this.
|
||||||
|
mockedNavigationStore.getScreensInStack.mockImplementation(() => []);
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
mockApiClient.get.mockReset();
|
||||||
|
mockApiClient.post.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const client = await NetworkManager.createClient(serverUrl);
|
||||||
|
expect(client).toBeTruthy();
|
||||||
|
operator = (await TestHelper.setupServerDatabase(serverUrl)).operator;
|
||||||
|
await DatabaseManager.setActiveServerDatabase(serverUrl);
|
||||||
|
post = TestHelper.fakePost(TestHelper.basicChannel!.id);
|
||||||
|
await operator.handlePosts({
|
||||||
|
actionType: ActionType.POSTS.RECEIVED_NEW,
|
||||||
|
order: [post.id],
|
||||||
|
posts: [post],
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
});
|
||||||
|
const threadModels = await prepareThreadsFromReceivedPosts(operator, [post], true);
|
||||||
|
await operator.batchRecords(threadModels, 'test');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await TestHelper.tearDown();
|
||||||
|
NetworkManager.invalidateClient(serverUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('channel notification', async () => {
|
||||||
|
await operator.handleConfigs({
|
||||||
|
configs: [
|
||||||
|
{id: 'CollapsedThreads', value: 'default_on'},
|
||||||
|
{id: 'FeatureFlagCollapsedThreads', value: 'true'},
|
||||||
|
],
|
||||||
|
configsToDelete: [],
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await pushNotificationEntry(serverUrl, {
|
||||||
|
channel_id: TestHelper.basicChannel!.id,
|
||||||
|
team_id: TestHelper.basicTeam!.id,
|
||||||
|
isCRTEnabled: false, // isCRTEnabled is not checked at this level
|
||||||
|
post_id: '', // Post ID is not checked at this level
|
||||||
|
type: '', // Type is not checked at this level
|
||||||
|
version: '', // Version is not checked at this level
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(PerformanceMetricsManager.setLoadTarget).toHaveBeenCalledWith('CHANNEL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('thread notification', async () => {
|
||||||
|
const commentPost = TestHelper.fakePost(TestHelper.basicChannel!.id);
|
||||||
|
commentPost.root_id = post.id;
|
||||||
|
await operator.handlePosts({
|
||||||
|
actionType: ActionType.POSTS.RECEIVED_NEW,
|
||||||
|
order: [commentPost.id],
|
||||||
|
posts: [commentPost],
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
});
|
||||||
|
const threadModels = await prepareThreadsFromReceivedPosts(operator, [commentPost], true);
|
||||||
|
await operator.batchRecords(threadModels, 'test');
|
||||||
|
await operator.handleConfigs({
|
||||||
|
configs: [
|
||||||
|
{id: 'CollapsedThreads', value: 'default_on'},
|
||||||
|
{id: 'FeatureFlagCollapsedThreads', value: 'true'},
|
||||||
|
],
|
||||||
|
configsToDelete: [],
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await pushNotificationEntry(serverUrl, {
|
||||||
|
root_id: post.id,
|
||||||
|
channel_id: TestHelper.basicChannel!.id,
|
||||||
|
team_id: TestHelper.basicTeam!.id,
|
||||||
|
isCRTEnabled: false, // isCRTEnabled is not checked at this level
|
||||||
|
post_id: '', // Post ID is not checked at this level
|
||||||
|
type: '', // Type is not checked at this level
|
||||||
|
version: '', // Version is not checked at this level
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(PerformanceMetricsManager.setLoadTarget).toHaveBeenCalledWith('THREAD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('thread notification with wrong root id', async () => {
|
||||||
|
const commentPost = TestHelper.fakePost(TestHelper.basicChannel!.id);
|
||||||
|
commentPost.root_id = post.id;
|
||||||
|
await operator.handlePosts({
|
||||||
|
actionType: ActionType.POSTS.RECEIVED_NEW,
|
||||||
|
order: [commentPost.id],
|
||||||
|
posts: [commentPost],
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
});
|
||||||
|
const threadModels = await prepareThreadsFromReceivedPosts(operator, [commentPost], true);
|
||||||
|
await operator.batchRecords(threadModels, 'test');
|
||||||
|
await operator.handleConfigs({
|
||||||
|
configs: [
|
||||||
|
{id: 'CollapsedThreads', value: 'default_on'},
|
||||||
|
{id: 'FeatureFlagCollapsedThreads', value: 'true'},
|
||||||
|
],
|
||||||
|
configsToDelete: [],
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await pushNotificationEntry(serverUrl, {
|
||||||
|
root_id: commentPost.id,
|
||||||
|
channel_id: TestHelper.basicChannel!.id,
|
||||||
|
team_id: TestHelper.basicTeam!.id,
|
||||||
|
isCRTEnabled: false, // isCRTEnabled is not checked at this level
|
||||||
|
post_id: '', // Post ID is not checked at this level
|
||||||
|
type: '', // Type is not checked at this level
|
||||||
|
version: '', // Version is not checked at this level
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(PerformanceMetricsManager.setLoadTarget).toHaveBeenCalledWith('THREAD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('thread notification with non crt', async () => {
|
||||||
|
const commentPost = TestHelper.fakePost(TestHelper.basicChannel!.id);
|
||||||
|
commentPost.root_id = post.id;
|
||||||
|
await operator.handlePosts({
|
||||||
|
actionType: ActionType.POSTS.RECEIVED_NEW,
|
||||||
|
order: [commentPost.id],
|
||||||
|
posts: [commentPost],
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
});
|
||||||
|
const threadModels = await prepareThreadsFromReceivedPosts(operator, [commentPost], true);
|
||||||
|
await operator.batchRecords(threadModels, 'test');
|
||||||
|
await operator.handleConfigs({
|
||||||
|
configs: [
|
||||||
|
{id: 'CollapsedThreads', value: 'disabled'},
|
||||||
|
{id: 'FeatureFlagCollapsedThreads', value: 'false'},
|
||||||
|
],
|
||||||
|
configsToDelete: [],
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await pushNotificationEntry(serverUrl, {
|
||||||
|
root_id: post.id,
|
||||||
|
channel_id: TestHelper.basicChannel!.id,
|
||||||
|
team_id: TestHelper.basicTeam!.id,
|
||||||
|
isCRTEnabled: false, // isCRTEnabled is not checked at this level
|
||||||
|
post_id: '', // Post ID is not checked at this level
|
||||||
|
type: '', // Type is not checked at this level
|
||||||
|
version: '', // Version is not checked at this level
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(PerformanceMetricsManager.setLoadTarget).toHaveBeenCalledWith('CHANNEL');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -7,6 +7,7 @@ import {fetchMyTeam} from '@actions/remote/team';
|
||||||
import {fetchAndSwitchToThread} from '@actions/remote/thread';
|
import {fetchAndSwitchToThread} from '@actions/remote/thread';
|
||||||
import {getDefaultThemeByAppearance} from '@context/theme';
|
import {getDefaultThemeByAppearance} from '@context/theme';
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
|
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
|
||||||
import WebsocketManager from '@managers/websocket_manager';
|
import WebsocketManager from '@managers/websocket_manager';
|
||||||
import {getMyChannel} from '@queries/servers/channel';
|
import {getMyChannel} from '@queries/servers/channel';
|
||||||
import {getPostById} from '@queries/servers/post';
|
import {getPostById} from '@queries/servers/post';
|
||||||
|
|
@ -103,13 +104,16 @@ export async function pushNotificationEntry(serverUrl: string, notification: Not
|
||||||
const actualRootId = post && ('root_id' in post ? post.root_id : post.rootId);
|
const actualRootId = post && ('root_id' in post ? post.root_id : post.rootId);
|
||||||
|
|
||||||
if (actualRootId) {
|
if (actualRootId) {
|
||||||
|
PerformanceMetricsManager.setLoadTarget('THREAD');
|
||||||
await fetchAndSwitchToThread(serverUrl, actualRootId, true);
|
await fetchAndSwitchToThread(serverUrl, actualRootId, true);
|
||||||
} else if (post) {
|
} else if (post) {
|
||||||
|
PerformanceMetricsManager.setLoadTarget('THREAD');
|
||||||
await fetchAndSwitchToThread(serverUrl, rootId, true);
|
await fetchAndSwitchToThread(serverUrl, rootId, true);
|
||||||
} else {
|
} else {
|
||||||
emitNotificationError('Post');
|
emitNotificationError('Post');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
PerformanceMetricsManager.setLoadTarget('CHANNEL');
|
||||||
await switchToChannelById(serverUrl, channelId, teamId);
|
await switchToChannelById(serverUrl, channelId, teamId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
67
app/actions/remote/performance.test.ts
Normal file
67
app/actions/remote/performance.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import NetworkManager from '@managers/network_manager';
|
||||||
|
import {mockApiClient} from '@test/mock_api_client';
|
||||||
|
|
||||||
|
import {sendPerformanceReport} from './performance';
|
||||||
|
|
||||||
|
describe('sendPerformanceReport', () => {
|
||||||
|
const serverUrl = 'http://www.someserverurl.com';
|
||||||
|
const report: PerformanceReport = {
|
||||||
|
counters: [],
|
||||||
|
start: 1234,
|
||||||
|
end: 1235,
|
||||||
|
histograms: [
|
||||||
|
{
|
||||||
|
metric: 'metric1',
|
||||||
|
timestamp: 1234,
|
||||||
|
value: 123,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: 'metric1',
|
||||||
|
timestamp: 1234,
|
||||||
|
value: 124,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: 'metric2',
|
||||||
|
timestamp: 1234,
|
||||||
|
value: 125,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
labels: {
|
||||||
|
agent: 'rnapp',
|
||||||
|
platform: 'ios',
|
||||||
|
},
|
||||||
|
version: '0.1.0',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
mockApiClient.post.mockImplementation(() => ({status: 200, ok: true}));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
mockApiClient.post.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const client = await NetworkManager.createClient(serverUrl);
|
||||||
|
expect(client).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
NetworkManager.invalidateClient(serverUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('happy path', async () => {
|
||||||
|
const {error} = await sendPerformanceReport(serverUrl, report);
|
||||||
|
expect(error).toBeFalsy();
|
||||||
|
expect(mockApiClient.post).toHaveBeenCalledWith(`${serverUrl}/api/v4/client_perf`, {body: report, headers: {}});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('properly returns error', async () => {
|
||||||
|
mockApiClient.post.mockImplementationOnce(() => ({status: 404, ok: false}));
|
||||||
|
const {error} = await sendPerformanceReport(serverUrl, report);
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
20
app/actions/remote/performance.ts
Normal file
20
app/actions/remote/performance.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import NetworkManager from '@managers/network_manager';
|
||||||
|
import {getFullErrorMessage} from '@utils/errors';
|
||||||
|
import {logDebug} from '@utils/log';
|
||||||
|
|
||||||
|
import {forceLogoutIfNecessary} from './session';
|
||||||
|
|
||||||
|
export const sendPerformanceReport = async (serverUrl: string, report: PerformanceReport) => {
|
||||||
|
try {
|
||||||
|
const client = NetworkManager.getClient(serverUrl);
|
||||||
|
await client.sendPerformanceReport(report);
|
||||||
|
return {};
|
||||||
|
} catch (error) {
|
||||||
|
logDebug('error on sendPerformanceReport', getFullErrorMessage(error));
|
||||||
|
forceLogoutIfNecessary(serverUrl, error);
|
||||||
|
return {error};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -52,7 +52,7 @@ type AuthorsRequest = {
|
||||||
error?: unknown;
|
error?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPost(serverUrl: string, post: Partial<Post>, files: FileInfo[] = []): Promise<{data?: boolean; error?: any}> {
|
export async function createPost(serverUrl: string, post: Partial<Post>, files: FileInfo[] = []): Promise<{data?: boolean; error?: unknown}> {
|
||||||
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
|
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
|
||||||
if (!operator) {
|
if (!operator) {
|
||||||
return {error: `${serverUrl} database not found`};
|
return {error: `${serverUrl} database not found`};
|
||||||
|
|
@ -571,7 +571,7 @@ export async function fetchPostThread(serverUrl: string, postId: string, options
|
||||||
});
|
});
|
||||||
const result = processPostsFetched(data);
|
const result = processPostsFetched(data);
|
||||||
let posts: Model[] = [];
|
let posts: Model[] = [];
|
||||||
if (!fetchOnly) {
|
if (result.posts.length && !fetchOnly) {
|
||||||
const models: Model[] = [];
|
const models: Model[] = [];
|
||||||
posts = await operator.handlePosts({
|
posts = await operator.handlePosts({
|
||||||
...result,
|
...result,
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,10 @@ export default class ClientBase {
|
||||||
return this.getPluginRoute(Calls.PluginId);
|
return this.getPluginRoute(Calls.PluginId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getPerformanceRoute() {
|
||||||
|
return `${this.urlVersion}/client_perf`;
|
||||||
|
}
|
||||||
|
|
||||||
doFetch = async (url: string, options: ClientOptions, returnDataOnly = true) => {
|
doFetch = async (url: string, options: ClientOptions, returnDataOnly = true) => {
|
||||||
let request;
|
let request;
|
||||||
const method = options.method?.toLowerCase();
|
const method = options.method?.toLowerCase();
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export interface ClientGeneralMix {
|
||||||
getChannelDataRetentionPolicies: (userId: string, page?: number, perPage?: number) => Promise<PoliciesResponse<ChannelDataRetentionPolicy>>;
|
getChannelDataRetentionPolicies: (userId: string, page?: number, perPage?: number) => Promise<PoliciesResponse<ChannelDataRetentionPolicy>>;
|
||||||
getRolesByNames: (rolesNames: string[]) => Promise<Role[]>;
|
getRolesByNames: (rolesNames: string[]) => Promise<Role[]>;
|
||||||
getRedirectLocation: (urlParam: string) => Promise<Record<string, string>>;
|
getRedirectLocation: (urlParam: string) => Promise<Record<string, string>>;
|
||||||
|
sendPerformanceReport: (batch: PerformanceReport) => Promise<{}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ClientGeneral = <TBase extends Constructor<ClientBase>>(superclass: TBase) => class extends superclass {
|
const ClientGeneral = <TBase extends Constructor<ClientBase>>(superclass: TBase) => class extends superclass {
|
||||||
|
|
@ -111,6 +112,13 @@ const ClientGeneral = <TBase extends Constructor<ClientBase>>(superclass: TBase)
|
||||||
const url = `${this.getRedirectLocationRoute()}${buildQueryString({url: urlParam})}`;
|
const url = `${this.getRedirectLocationRoute()}${buildQueryString({url: urlParam})}`;
|
||||||
return this.doFetch(url, {method: 'get'});
|
return this.doFetch(url, {method: 'get'});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
sendPerformanceReport = async (report: PerformanceReport) => {
|
||||||
|
return this.doFetch(
|
||||||
|
this.getPerformanceRoute(),
|
||||||
|
{method: 'post', body: report},
|
||||||
|
);
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ClientGeneral;
|
export default ClientGeneral;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ type LoadingProps = {
|
||||||
themeColor?: keyof Theme;
|
themeColor?: keyof Theme;
|
||||||
footerText?: string;
|
footerText?: string;
|
||||||
footerTextStyles?: TextStyle;
|
footerTextStyles?: TextStyle;
|
||||||
|
testID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Loading = ({
|
const Loading = ({
|
||||||
|
|
@ -22,12 +23,16 @@ const Loading = ({
|
||||||
themeColor,
|
themeColor,
|
||||||
footerText,
|
footerText,
|
||||||
footerTextStyles,
|
footerTextStyles,
|
||||||
|
testID,
|
||||||
}: LoadingProps) => {
|
}: LoadingProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const indicatorColor = themeColor ? theme[themeColor] : color;
|
const indicatorColor = themeColor ? theme[themeColor] : color;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={containerStyle}>
|
<View
|
||||||
|
style={containerStyle}
|
||||||
|
testID={testID}
|
||||||
|
>
|
||||||
<ActivityIndicator
|
<ActivityIndicator
|
||||||
color={indicatorColor}
|
color={indicatorColor}
|
||||||
size={size}
|
size={size}
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,7 @@ const withPost = withObservables(
|
||||||
post: post.observe(),
|
post: post.observe(),
|
||||||
thread: isCRTEnabled ? observeThreadById(database, post.id) : of$(undefined),
|
thread: isCRTEnabled ? observeThreadById(database, post.id) : of$(undefined),
|
||||||
hasReactions,
|
hasReactions,
|
||||||
|
isLastPost: of$(!nextPost),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
75
app/components/post_list/post/post.test.tsx
Normal file
75
app/components/post_list/post/post.test.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React, {type ComponentProps} from 'react';
|
||||||
|
|
||||||
|
import NetworkManager from '@managers/network_manager';
|
||||||
|
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
|
||||||
|
import {getPostById} from '@queries/servers/post';
|
||||||
|
import {renderWithEverything} from '@test/intl-test-helper';
|
||||||
|
import TestHelper from '@test/test_helper';
|
||||||
|
|
||||||
|
import Post from './post';
|
||||||
|
|
||||||
|
import type {Database} from '@nozbe/watermelondb';
|
||||||
|
import type PostModel from '@typings/database/models/servers/post';
|
||||||
|
|
||||||
|
jest.mock('@managers/performance_metrics_manager');
|
||||||
|
|
||||||
|
describe('performance metrics', () => {
|
||||||
|
let database: Database;
|
||||||
|
let post: PostModel;
|
||||||
|
|
||||||
|
function getBaseProps(): ComponentProps<typeof Post> {
|
||||||
|
return {
|
||||||
|
appsEnabled: false,
|
||||||
|
canDelete: false,
|
||||||
|
customEmojiNames: [],
|
||||||
|
differentThreadSequence: false,
|
||||||
|
hasFiles: false,
|
||||||
|
hasReactions: false,
|
||||||
|
hasReplies: false,
|
||||||
|
highlightReplyBar: false,
|
||||||
|
isEphemeral: false,
|
||||||
|
isPostAddChannelMember: false,
|
||||||
|
isPostPriorityEnabled: false,
|
||||||
|
location: 'Channel',
|
||||||
|
post,
|
||||||
|
isLastPost: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverUrl = 'http://www.someserverurl.com';
|
||||||
|
beforeEach(async () => {
|
||||||
|
const client = await NetworkManager.createClient(serverUrl);
|
||||||
|
expect(client).toBeTruthy();
|
||||||
|
database = (await TestHelper.setupServerDatabase(serverUrl)).database;
|
||||||
|
post = (await getPostById(database, TestHelper.basicPost!.id))!;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await TestHelper.tearDown();
|
||||||
|
NetworkManager.invalidateClient(serverUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('do not call the performance metrics if it is not the last post', () => {
|
||||||
|
const props = getBaseProps();
|
||||||
|
props.isLastPost = false;
|
||||||
|
renderWithEverything(<Post {...props}/>, {database, serverUrl});
|
||||||
|
expect(PerformanceMetricsManager.finishLoad).not.toHaveBeenCalled();
|
||||||
|
expect(PerformanceMetricsManager.endMetric).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('on channel', () => {
|
||||||
|
const props = getBaseProps();
|
||||||
|
renderWithEverything(<Post {...props}/>, {database, serverUrl});
|
||||||
|
expect(PerformanceMetricsManager.finishLoad).toHaveBeenCalledWith('CHANNEL', serverUrl);
|
||||||
|
expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledWith('mobile_channel_switch', serverUrl);
|
||||||
|
});
|
||||||
|
it('on thread', () => {
|
||||||
|
const props = getBaseProps();
|
||||||
|
props.location = 'Thread';
|
||||||
|
renderWithEverything(<Post {...props}/>, {database, serverUrl});
|
||||||
|
expect(PerformanceMetricsManager.finishLoad).toHaveBeenCalledWith('THREAD', serverUrl);
|
||||||
|
expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledWith('mobile_channel_switch', serverUrl);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -17,6 +17,7 @@ import * as Screens from '@constants/screens';
|
||||||
import {useServerUrl} from '@context/server';
|
import {useServerUrl} from '@context/server';
|
||||||
import {useTheme} from '@context/theme';
|
import {useTheme} from '@context/theme';
|
||||||
import {useIsTablet} from '@hooks/device';
|
import {useIsTablet} from '@hooks/device';
|
||||||
|
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
|
||||||
import {openAsBottomSheet} from '@screens/navigation';
|
import {openAsBottomSheet} from '@screens/navigation';
|
||||||
import {hasJumboEmojiOnly} from '@utils/emoji/helpers';
|
import {hasJumboEmojiOnly} from '@utils/emoji/helpers';
|
||||||
import {fromAutoResponder, isFromWebhook, isPostFailed, isPostPendingOrFailed, isSystemMessage} from '@utils/post';
|
import {fromAutoResponder, isFromWebhook, isPostFailed, isPostPendingOrFailed, isSystemMessage} from '@utils/post';
|
||||||
|
|
@ -60,6 +61,7 @@ type PostProps = {
|
||||||
post: PostModel;
|
post: PostModel;
|
||||||
rootId?: string;
|
rootId?: string;
|
||||||
previousPost?: PostModel;
|
previousPost?: PostModel;
|
||||||
|
isLastPost: boolean;
|
||||||
hasReactions: boolean;
|
hasReactions: boolean;
|
||||||
searchPatterns?: SearchPattern[];
|
searchPatterns?: SearchPattern[];
|
||||||
shouldRenderReplyButton?: boolean;
|
shouldRenderReplyButton?: boolean;
|
||||||
|
|
@ -109,10 +111,39 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const Post = ({
|
const Post = ({
|
||||||
appsEnabled, canDelete, currentUser, customEmojiNames, differentThreadSequence, hasFiles, hasReplies, highlight, highlightPinnedOrSaved = true, highlightReplyBar,
|
appsEnabled,
|
||||||
isCRTEnabled, isConsecutivePost, isEphemeral, isFirstReply, isSaved, isLastReply, isPostAcknowledgementEnabled, isPostAddChannelMember, isPostPriorityEnabled,
|
canDelete,
|
||||||
location, post, rootId, hasReactions, searchPatterns, shouldRenderReplyButton, skipSavedHeader, skipPinnedHeader, showAddReaction = true, style,
|
currentUser,
|
||||||
testID, thread, previousPost,
|
customEmojiNames,
|
||||||
|
differentThreadSequence,
|
||||||
|
hasFiles,
|
||||||
|
hasReplies,
|
||||||
|
highlight,
|
||||||
|
highlightPinnedOrSaved = true,
|
||||||
|
highlightReplyBar,
|
||||||
|
isCRTEnabled,
|
||||||
|
isConsecutivePost,
|
||||||
|
isEphemeral,
|
||||||
|
isFirstReply,
|
||||||
|
isSaved,
|
||||||
|
isLastReply,
|
||||||
|
isPostAcknowledgementEnabled,
|
||||||
|
isPostAddChannelMember,
|
||||||
|
isPostPriorityEnabled,
|
||||||
|
location,
|
||||||
|
post,
|
||||||
|
rootId,
|
||||||
|
hasReactions,
|
||||||
|
searchPatterns,
|
||||||
|
shouldRenderReplyButton,
|
||||||
|
skipSavedHeader,
|
||||||
|
skipPinnedHeader,
|
||||||
|
showAddReaction = true,
|
||||||
|
style,
|
||||||
|
testID,
|
||||||
|
thread,
|
||||||
|
previousPost,
|
||||||
|
isLastPost,
|
||||||
}: PostProps) => {
|
}: PostProps) => {
|
||||||
const pressDetected = useRef(false);
|
const pressDetected = useRef(false);
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
@ -216,6 +247,19 @@ const Post = ({
|
||||||
};
|
};
|
||||||
}, [post.id]);
|
}, [post.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLastPost) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location !== 'Channel' && location !== 'Thread') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PerformanceMetricsManager.finishLoad(location === 'Thread' ? 'THREAD' : 'CHANNEL', serverUrl);
|
||||||
|
PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const highlightSaved = isSaved && !skipSavedHeader;
|
const highlightSaved = isSaved && !skipSavedHeader;
|
||||||
const hightlightPinned = post.isPinned && !skipPinnedHeader;
|
const hightlightPinned = post.isPinned && !skipPinnedHeader;
|
||||||
const itemTestID = `${testID}.${post.id}`;
|
const itemTestID = `${testID}.${post.id}`;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {render, screen, userEvent} from '@testing-library/react-native';
|
||||||
|
import React, {type ComponentProps} from 'react';
|
||||||
|
|
||||||
|
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
|
||||||
|
import {getTeamById} from '@queries/servers/team';
|
||||||
|
import TestHelper from '@test/test_helper';
|
||||||
|
|
||||||
|
import TeamItem from './team_item';
|
||||||
|
|
||||||
|
import type TeamModel from '@typings/database/models/servers/team';
|
||||||
|
|
||||||
|
jest.mock('@managers/performance_metrics_manager');
|
||||||
|
|
||||||
|
function getBaseProps(): ComponentProps<typeof TeamItem> {
|
||||||
|
return {
|
||||||
|
hasUnreads: false,
|
||||||
|
mentionCount: 0,
|
||||||
|
selected: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('performance metrics', () => {
|
||||||
|
const serverUrl = 'http://www.someserverurl.com';
|
||||||
|
let team: TeamModel | undefined;
|
||||||
|
beforeAll(() => {
|
||||||
|
jest.useFakeTimers({legacyFakeTimers: true});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
userEvent.setup({advanceTimers: jest.advanceTimersByTime});
|
||||||
|
const {database} = await TestHelper.setupServerDatabase(serverUrl);
|
||||||
|
team = await getTeamById(database, TestHelper.basicTeam!.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await TestHelper.tearDown();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('happy path', async () => {
|
||||||
|
const baseProps = getBaseProps();
|
||||||
|
baseProps.team = team;
|
||||||
|
render(<TeamItem {...baseProps}/>);
|
||||||
|
const button = await screen.findByTestId(`team_sidebar.team_list.team_item.${team!.id}.not_selected`);
|
||||||
|
await userEvent.press(button);
|
||||||
|
expect(PerformanceMetricsManager.startMetric).toHaveBeenCalledWith('mobile_team_switch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('do not start when the team is already selected', async () => {
|
||||||
|
const baseProps = getBaseProps();
|
||||||
|
baseProps.team = team;
|
||||||
|
baseProps.selected = true;
|
||||||
|
render(<TeamItem {...baseProps}/>);
|
||||||
|
const button = await screen.findByTestId(`team_sidebar.team_list.team_item.${team!.id}.selected`);
|
||||||
|
await userEvent.press(button);
|
||||||
|
expect(PerformanceMetricsManager.startMetric).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import React from 'react';
|
import React, {useCallback} from 'react';
|
||||||
import {View} from 'react-native';
|
import {View} from 'react-native';
|
||||||
|
|
||||||
import {handleTeamChange} from '@actions/remote/team';
|
import {handleTeamChange} from '@actions/remote/team';
|
||||||
|
|
@ -9,6 +9,7 @@ import Badge from '@components/badge';
|
||||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||||
import {useServerUrl} from '@context/server';
|
import {useServerUrl} from '@context/server';
|
||||||
import {useTheme} from '@context/theme';
|
import {useTheme} from '@context/theme';
|
||||||
|
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
|
||||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
||||||
import TeamIcon from './team_icon';
|
import TeamIcon from './team_icon';
|
||||||
|
|
@ -62,6 +63,15 @@ export default function TeamItem({team, hasUnreads, mentionCount, selected}: Pro
|
||||||
const styles = getStyleSheet(theme);
|
const styles = getStyleSheet(theme);
|
||||||
const serverUrl = useServerUrl();
|
const serverUrl = useServerUrl();
|
||||||
|
|
||||||
|
const onPress = useCallback(() => {
|
||||||
|
if (!team || selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PerformanceMetricsManager.startMetric('mobile_team_switch');
|
||||||
|
handleTeamChange(serverUrl, team.id);
|
||||||
|
}, [selected, team?.id, serverUrl]);
|
||||||
|
|
||||||
if (!team) {
|
if (!team) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -92,7 +102,7 @@ export default function TeamItem({team, hasUnreads, mentionCount, selected}: Pro
|
||||||
<>
|
<>
|
||||||
<View style={[styles.container, selected ? styles.containerSelected : undefined]}>
|
<View style={[styles.container, selected ? styles.containerSelected : undefined]}>
|
||||||
<TouchableWithFeedback
|
<TouchableWithFeedback
|
||||||
onPress={() => handleTeamChange(serverUrl, team.id)}
|
onPress={onPress}
|
||||||
type='opacity'
|
type='opacity'
|
||||||
testID={teamItemTestId}
|
testID={teamItemTestId}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import LocalConfig from '@assets/config.json';
|
||||||
import {DeepLink, Events, Launch, PushNotification} from '@constants';
|
import {DeepLink, Events, Launch, PushNotification} from '@constants';
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
import {getActiveServerUrl, getServerCredentials, removeServerCredentials} from '@init/credentials';
|
import {getActiveServerUrl, getServerCredentials, removeServerCredentials} from '@init/credentials';
|
||||||
|
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
|
||||||
import {getLastViewedChannelIdAndServer, getOnboardingViewed, getLastViewedThreadIdAndServer} from '@queries/app/global';
|
import {getLastViewedChannelIdAndServer, getOnboardingViewed, getLastViewedThreadIdAndServer} from '@queries/app/global';
|
||||||
import {getThemeForCurrentTeam} from '@queries/servers/preference';
|
import {getThemeForCurrentTeam} from '@queries/servers/preference';
|
||||||
import {getCurrentUserId} from '@queries/servers/system';
|
import {getCurrentUserId} from '@queries/servers/system';
|
||||||
|
|
@ -178,9 +179,13 @@ const launchToHome = async (props: LaunchProps) => {
|
||||||
const lastViewedThread = await getLastViewedThreadIdAndServer();
|
const lastViewedThread = await getLastViewedThreadIdAndServer();
|
||||||
|
|
||||||
if (lastViewedThread && lastViewedThread.server_url === props.serverUrl && lastViewedThread.thread_id) {
|
if (lastViewedThread && lastViewedThread.server_url === props.serverUrl && lastViewedThread.thread_id) {
|
||||||
|
PerformanceMetricsManager.setLoadTarget('THREAD');
|
||||||
fetchAndSwitchToThread(props.serverUrl!, lastViewedThread.thread_id);
|
fetchAndSwitchToThread(props.serverUrl!, lastViewedThread.thread_id);
|
||||||
} else if (lastViewedChannel && lastViewedChannel.server_url === props.serverUrl && lastViewedChannel.channel_id) {
|
} else if (lastViewedChannel && lastViewedChannel.server_url === props.serverUrl && lastViewedChannel.channel_id) {
|
||||||
|
PerformanceMetricsManager.setLoadTarget('CHANNEL');
|
||||||
switchToChannelById(props.serverUrl!, lastViewedChannel.channel_id);
|
switchToChannelById(props.serverUrl!, lastViewedChannel.channel_id);
|
||||||
|
} else {
|
||||||
|
PerformanceMetricsManager.setLoadTarget('HOME');
|
||||||
}
|
}
|
||||||
|
|
||||||
appEntry(props.serverUrl!);
|
appEntry(props.serverUrl!);
|
||||||
|
|
|
||||||
209
app/managers/performance_metrics_manager/index.test.ts
Normal file
209
app/managers/performance_metrics_manager/index.test.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import performance from 'react-native-performance';
|
||||||
|
|
||||||
|
import {mockApiClient} from '@test/mock_api_client';
|
||||||
|
import TestHelper from '@test/test_helper';
|
||||||
|
|
||||||
|
import NetworkManager from '../network_manager';
|
||||||
|
|
||||||
|
import {getBaseReportRequest} from './test_utils';
|
||||||
|
|
||||||
|
import PerformanceMetricsManager from '.';
|
||||||
|
|
||||||
|
const TEST_EPOCH = 1577836800000;
|
||||||
|
jest.mock('@utils/log', () => ({
|
||||||
|
logDebug: () => '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
performance.timeOrigin = TEST_EPOCH;
|
||||||
|
|
||||||
|
describe('load metrics', () => {
|
||||||
|
const serverUrl = 'http://www.someserverurl.com/';
|
||||||
|
const expectedUrl = `${serverUrl}/api/v4/client_perf`;
|
||||||
|
|
||||||
|
const measure: PerformanceReportMeasure = {
|
||||||
|
metric: 'mobile_load',
|
||||||
|
timestamp: TEST_EPOCH + 61000,
|
||||||
|
value: 61000,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
NetworkManager.createClient(serverUrl);
|
||||||
|
const {operator} = await TestHelper.setupServerDatabase(serverUrl);
|
||||||
|
await operator.handleConfigs({configs: [{id: 'EnableClientMetrics', value: 'true'}], configsToDelete: [], prepareRecordsOnly: false});
|
||||||
|
jest.useFakeTimers({doNotFake: [
|
||||||
|
'cancelAnimationFrame',
|
||||||
|
'cancelIdleCallback',
|
||||||
|
'clearImmediate',
|
||||||
|
'clearInterval',
|
||||||
|
'clearTimeout',
|
||||||
|
'hrtime',
|
||||||
|
'nextTick',
|
||||||
|
'queueMicrotask',
|
||||||
|
'requestAnimationFrame',
|
||||||
|
'requestIdleCallback',
|
||||||
|
'setImmediate',
|
||||||
|
'setInterval',
|
||||||
|
]}).setSystemTime(new Date(TEST_EPOCH));
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
NetworkManager.invalidateClient(serverUrl);
|
||||||
|
await TestHelper.tearDown();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only load on target', async () => {
|
||||||
|
performance.mark('nativeLaunchStart');
|
||||||
|
const expectedRequest = getBaseReportRequest(measure.timestamp, measure.timestamp + 1);
|
||||||
|
expectedRequest.body.histograms = [measure];
|
||||||
|
|
||||||
|
PerformanceMetricsManager.setLoadTarget('CHANNEL');
|
||||||
|
PerformanceMetricsManager.finishLoad('HOME', serverUrl);
|
||||||
|
await TestHelper.tick();
|
||||||
|
jest.advanceTimersByTime(61000);
|
||||||
|
await TestHelper.tick();
|
||||||
|
expect(mockApiClient.post).not.toHaveBeenCalled();
|
||||||
|
PerformanceMetricsManager.finishLoad('CHANNEL', serverUrl);
|
||||||
|
await TestHelper.tick();
|
||||||
|
jest.advanceTimersByTime(61000);
|
||||||
|
await TestHelper.tick();
|
||||||
|
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('other metrics', () => {
|
||||||
|
const serverUrl1 = 'http://www.someserverurl.com/';
|
||||||
|
const expectedUrl1 = `${serverUrl1}/api/v4/client_perf`;
|
||||||
|
|
||||||
|
const serverUrl2 = 'http://www.otherserverurl.com/';
|
||||||
|
const expectedUrl2 = `${serverUrl2}/api/v4/client_perf`;
|
||||||
|
|
||||||
|
const measure1: PerformanceReportMeasure = {
|
||||||
|
metric: 'mobile_channel_switch',
|
||||||
|
timestamp: TEST_EPOCH + 100,
|
||||||
|
value: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
const measure2: PerformanceReportMeasure = {
|
||||||
|
metric: 'mobile_team_switch',
|
||||||
|
timestamp: TEST_EPOCH + 150 + 50,
|
||||||
|
value: 150,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
NetworkManager.createClient(serverUrl1);
|
||||||
|
NetworkManager.createClient(serverUrl2);
|
||||||
|
const {operator: operator1} = await TestHelper.setupServerDatabase(serverUrl1);
|
||||||
|
const {operator: operator2} = await TestHelper.setupServerDatabase(serverUrl2);
|
||||||
|
await operator1.handleConfigs({configs: [{id: 'EnableClientMetrics', value: 'true'}], configsToDelete: [], prepareRecordsOnly: false});
|
||||||
|
await operator2.handleConfigs({configs: [{id: 'EnableClientMetrics', value: 'true'}], configsToDelete: [], prepareRecordsOnly: false});
|
||||||
|
jest.useFakeTimers({doNotFake: [
|
||||||
|
'cancelAnimationFrame',
|
||||||
|
'cancelIdleCallback',
|
||||||
|
'clearImmediate',
|
||||||
|
'clearInterval',
|
||||||
|
'clearTimeout',
|
||||||
|
'hrtime',
|
||||||
|
'nextTick',
|
||||||
|
'queueMicrotask',
|
||||||
|
'requestAnimationFrame',
|
||||||
|
'requestIdleCallback',
|
||||||
|
'setImmediate',
|
||||||
|
'setInterval',
|
||||||
|
]}).setSystemTime(new Date(TEST_EPOCH));
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
NetworkManager.invalidateClient(serverUrl1);
|
||||||
|
NetworkManager.invalidateClient(serverUrl2);
|
||||||
|
await TestHelper.tearDown();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('do not send metrics when we do not start them', async () => {
|
||||||
|
PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1);
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(61000);
|
||||||
|
await TestHelper.tick();
|
||||||
|
expect(mockApiClient.post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('send metric after it has been started', async () => {
|
||||||
|
const expectedRequest = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1);
|
||||||
|
expectedRequest.body.histograms = [measure1];
|
||||||
|
|
||||||
|
PerformanceMetricsManager.startMetric('mobile_channel_switch');
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
|
||||||
|
PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1);
|
||||||
|
await TestHelper.tick();
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(61000);
|
||||||
|
await TestHelper.tick();
|
||||||
|
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl1, expectedRequest);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a second end metric does not generate a second measure', async () => {
|
||||||
|
const expectedRequest = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1);
|
||||||
|
expectedRequest.body.histograms = [measure1];
|
||||||
|
|
||||||
|
PerformanceMetricsManager.startMetric('mobile_channel_switch');
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
|
||||||
|
PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1);
|
||||||
|
await TestHelper.tick();
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
|
||||||
|
PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1);
|
||||||
|
await TestHelper.tick();
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(61000);
|
||||||
|
await TestHelper.tick();
|
||||||
|
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl1, expectedRequest);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('different metrics do not interfere', async () => {
|
||||||
|
const expectedRequest = getBaseReportRequest(measure1.timestamp, measure2.timestamp);
|
||||||
|
expectedRequest.body.histograms = [measure1, measure2];
|
||||||
|
|
||||||
|
PerformanceMetricsManager.startMetric('mobile_channel_switch');
|
||||||
|
jest.advanceTimersByTime(50);
|
||||||
|
PerformanceMetricsManager.startMetric('mobile_team_switch');
|
||||||
|
jest.advanceTimersByTime(50);
|
||||||
|
|
||||||
|
PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1);
|
||||||
|
await TestHelper.tick();
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
PerformanceMetricsManager.endMetric('mobile_team_switch', serverUrl1);
|
||||||
|
await TestHelper.tick();
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(61000);
|
||||||
|
await TestHelper.tick();
|
||||||
|
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl1, expectedRequest);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('metrics to different servers do not interfere', async () => {
|
||||||
|
const expectedRequest1 = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1);
|
||||||
|
expectedRequest1.body.histograms = [measure1];
|
||||||
|
|
||||||
|
const expectedRequest2 = getBaseReportRequest(measure2.timestamp, measure2.timestamp + 1);
|
||||||
|
expectedRequest2.body.histograms = [measure2];
|
||||||
|
|
||||||
|
PerformanceMetricsManager.startMetric('mobile_channel_switch');
|
||||||
|
jest.advanceTimersByTime(50);
|
||||||
|
PerformanceMetricsManager.startMetric('mobile_team_switch');
|
||||||
|
jest.advanceTimersByTime(50);
|
||||||
|
|
||||||
|
PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1);
|
||||||
|
await TestHelper.tick();
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
PerformanceMetricsManager.endMetric('mobile_team_switch', serverUrl2);
|
||||||
|
await TestHelper.tick();
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(61000);
|
||||||
|
await TestHelper.tick();
|
||||||
|
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl1, expectedRequest1);
|
||||||
|
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl2, expectedRequest2);
|
||||||
|
});
|
||||||
|
});
|
||||||
85
app/managers/performance_metrics_manager/index.ts
Normal file
85
app/managers/performance_metrics_manager/index.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {AppState, type AppStateStatus} from 'react-native';
|
||||||
|
import performance from 'react-native-performance';
|
||||||
|
|
||||||
|
import Batcher from './performance_metrics_batcher';
|
||||||
|
|
||||||
|
type Target = 'HOME' | 'CHANNEL' | 'THREAD' | undefined;
|
||||||
|
type MetricName = 'mobile_channel_switch' |
|
||||||
|
'mobile_team_switch';
|
||||||
|
|
||||||
|
class PerformanceMetricsManager {
|
||||||
|
private target: Target;
|
||||||
|
private batchers: {[serverUrl: string]: Batcher} = {};
|
||||||
|
private hasRegisteredLoad = false;
|
||||||
|
private lastAppStateIsActive = AppState.currentState === 'active';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
AppState.addEventListener('change', (appState) => this.onAppStateChange(appState));
|
||||||
|
}
|
||||||
|
|
||||||
|
private onAppStateChange(appState: AppStateStatus) {
|
||||||
|
const isAppStateActive = appState === 'active';
|
||||||
|
if (this.lastAppStateIsActive !== isAppStateActive && !isAppStateActive) {
|
||||||
|
for (const batcher of Object.values(this.batchers)) {
|
||||||
|
batcher.forceSend();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.lastAppStateIsActive = isAppStateActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureBatcher(serverUrl: string) {
|
||||||
|
if (this.batchers[serverUrl]) {
|
||||||
|
return this.batchers[serverUrl];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.batchers[serverUrl] = new Batcher(serverUrl);
|
||||||
|
return this.batchers[serverUrl];
|
||||||
|
}
|
||||||
|
|
||||||
|
public setLoadTarget(target: Target) {
|
||||||
|
this.target = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
public finishLoad(location: Target, serverUrl: string) {
|
||||||
|
if (this.target !== location || this.hasRegisteredLoad) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const measure = performance.measure('mobile_load', 'nativeLaunchStart');
|
||||||
|
this.ensureBatcher(serverUrl).addToBatch({
|
||||||
|
metric: 'mobile_load',
|
||||||
|
value: measure.duration,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
performance.clearMeasures('mobile_load');
|
||||||
|
this.hasRegisteredLoad = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public startMetric(metricName: MetricName) {
|
||||||
|
performance.mark(metricName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public endMetric(metricName: MetricName, serverUrl: string) {
|
||||||
|
const marks = performance.getEntriesByName(metricName, 'mark');
|
||||||
|
if (!marks.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const measureName = `${metricName}_measure`;
|
||||||
|
const measure = performance.measure(measureName, metricName);
|
||||||
|
|
||||||
|
this.ensureBatcher(serverUrl).addToBatch({
|
||||||
|
metric: metricName,
|
||||||
|
value: measure.duration,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
performance.clearMarks(metricName);
|
||||||
|
performance.clearMeasures(measureName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new PerformanceMetricsManager();
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import NetworkManager from '@managers/network_manager';
|
||||||
|
import {mockApiClient} from '@test/mock_api_client';
|
||||||
|
import TestHelper from '@test/test_helper';
|
||||||
|
|
||||||
|
import Batcher from './performance_metrics_batcher';
|
||||||
|
import {getBaseReportRequest} from './test_utils';
|
||||||
|
|
||||||
|
import type ServerDataOperator from '@database/operator/server_data_operator';
|
||||||
|
|
||||||
|
jest.mock('@utils/log', () => ({
|
||||||
|
logDebug: () => '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('perfromance metrics batcher', () => {
|
||||||
|
const serverUrl = 'http://www.someserverurl.com';
|
||||||
|
const expectedUrl = `${serverUrl}/api/v4/client_perf`;
|
||||||
|
|
||||||
|
const measure1: PerformanceReportMeasure = {
|
||||||
|
metric: 'someMetric',
|
||||||
|
timestamp: 1234,
|
||||||
|
value: 1.5,
|
||||||
|
};
|
||||||
|
let operator: ServerDataOperator;
|
||||||
|
|
||||||
|
const measure2: PerformanceReportMeasure = {
|
||||||
|
metric: 'someOtherMetric',
|
||||||
|
timestamp: 1235,
|
||||||
|
value: 2.5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const measure3: PerformanceReportMeasure = {
|
||||||
|
metric: 'yetAnother',
|
||||||
|
timestamp: 1236,
|
||||||
|
value: 0.5,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function setMetricsConfig(value: string) {
|
||||||
|
await operator.handleConfigs({configs: [{id: 'EnableClientMetrics', value}], configsToDelete: [], prepareRecordsOnly: false});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
NetworkManager.createClient(serverUrl);
|
||||||
|
operator = (await TestHelper.setupServerDatabase(serverUrl)).operator;
|
||||||
|
await setMetricsConfig('true');
|
||||||
|
jest.useFakeTimers({doNotFake: [
|
||||||
|
'Date',
|
||||||
|
'cancelAnimationFrame',
|
||||||
|
'cancelIdleCallback',
|
||||||
|
'clearImmediate',
|
||||||
|
'clearInterval',
|
||||||
|
'clearTimeout',
|
||||||
|
'hrtime',
|
||||||
|
'nextTick',
|
||||||
|
'performance',
|
||||||
|
'queueMicrotask',
|
||||||
|
'requestAnimationFrame',
|
||||||
|
'requestIdleCallback',
|
||||||
|
'setImmediate',
|
||||||
|
'setInterval',
|
||||||
|
]});
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
NetworkManager.invalidateClient(serverUrl);
|
||||||
|
await TestHelper.tearDown();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('properly send batches only after timeout', async () => {
|
||||||
|
const batcher = new Batcher(serverUrl);
|
||||||
|
|
||||||
|
const expectedRequest = getBaseReportRequest(measure1.timestamp, measure2.timestamp);
|
||||||
|
expectedRequest.body.histograms = [measure1, measure2];
|
||||||
|
|
||||||
|
batcher.addToBatch(measure1);
|
||||||
|
await TestHelper.tick();
|
||||||
|
expect(mockApiClient.post).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
batcher.addToBatch(measure2);
|
||||||
|
await TestHelper.tick();
|
||||||
|
expect(mockApiClient.post).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(61000);
|
||||||
|
await TestHelper.tick();
|
||||||
|
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('properly set end after start when only one element', async () => {
|
||||||
|
const batcher = new Batcher(serverUrl);
|
||||||
|
|
||||||
|
const expectedRequest = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1);
|
||||||
|
expectedRequest.body.histograms = [measure1];
|
||||||
|
|
||||||
|
batcher.addToBatch(measure1);
|
||||||
|
jest.advanceTimersByTime(61000);
|
||||||
|
await TestHelper.tick();
|
||||||
|
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('send the batch directly after maximum batch size is reached', async () => {
|
||||||
|
const batcher = new Batcher(serverUrl);
|
||||||
|
const expectedRequest = getBaseReportRequest(measure1.timestamp, measure2.timestamp);
|
||||||
|
for (let i = 0; i < 99; i++) {
|
||||||
|
batcher.addToBatch(measure1);
|
||||||
|
expectedRequest.body.histograms.push(measure1);
|
||||||
|
}
|
||||||
|
await TestHelper.tick();
|
||||||
|
expect(mockApiClient.post).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
batcher.addToBatch(measure2);
|
||||||
|
expectedRequest.body.histograms.push(measure2);
|
||||||
|
await TestHelper.tick();
|
||||||
|
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest);
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(61000);
|
||||||
|
await TestHelper.tick();
|
||||||
|
expect(mockApiClient.post).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('do not send batches when the config is set to false', async () => {
|
||||||
|
await setMetricsConfig('false');
|
||||||
|
const batcher = new Batcher(serverUrl);
|
||||||
|
batcher.addToBatch(measure2);
|
||||||
|
jest.advanceTimersByTime(61000);
|
||||||
|
await TestHelper.tick();
|
||||||
|
expect(mockApiClient.post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('do not send batches when the config is set to false even on force send', async () => {
|
||||||
|
await setMetricsConfig('false');
|
||||||
|
const batcher = new Batcher(serverUrl);
|
||||||
|
batcher.addToBatch(measure2);
|
||||||
|
batcher.forceSend();
|
||||||
|
await TestHelper.tick();
|
||||||
|
expect(mockApiClient.post).not.toHaveBeenCalled();
|
||||||
|
jest.advanceTimersByTime(61000);
|
||||||
|
await TestHelper.tick();
|
||||||
|
expect(mockApiClient.post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('old elements do not drip into the next batch', async () => {
|
||||||
|
const batcher = new Batcher(serverUrl);
|
||||||
|
let expectedRequest = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1);
|
||||||
|
expectedRequest.body.histograms = [measure1];
|
||||||
|
|
||||||
|
batcher.addToBatch(measure1);
|
||||||
|
jest.advanceTimersByTime(61000);
|
||||||
|
await TestHelper.tick();
|
||||||
|
expect(mockApiClient.post).toHaveBeenLastCalledWith(expectedUrl, expectedRequest);
|
||||||
|
|
||||||
|
expectedRequest = getBaseReportRequest(measure2.timestamp, measure2.timestamp + 1);
|
||||||
|
expectedRequest.body.histograms = [];
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
batcher.addToBatch(measure2);
|
||||||
|
expectedRequest.body.histograms.push(measure2);
|
||||||
|
}
|
||||||
|
await TestHelper.tick();
|
||||||
|
expect(mockApiClient.post).toHaveBeenLastCalledWith(expectedUrl, expectedRequest);
|
||||||
|
|
||||||
|
expectedRequest = getBaseReportRequest(measure3.timestamp, measure3.timestamp + 1);
|
||||||
|
expectedRequest.body.histograms = [measure3];
|
||||||
|
|
||||||
|
batcher.addToBatch(measure3);
|
||||||
|
jest.advanceTimersByTime(61000);
|
||||||
|
await TestHelper.tick();
|
||||||
|
expect(mockApiClient.post).toHaveBeenLastCalledWith(expectedUrl, expectedRequest);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('force send sends the batch, and does not get resent after the timeout', async () => {
|
||||||
|
const batcher = new Batcher(serverUrl);
|
||||||
|
|
||||||
|
const expectedRequest = getBaseReportRequest(measure1.timestamp, measure2.timestamp);
|
||||||
|
expectedRequest.body.histograms = [measure1, measure2];
|
||||||
|
|
||||||
|
batcher.addToBatch(measure1);
|
||||||
|
await TestHelper.tick();
|
||||||
|
expect(mockApiClient.post).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
batcher.addToBatch(measure2);
|
||||||
|
await TestHelper.tick();
|
||||||
|
expect(mockApiClient.post).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(61000);
|
||||||
|
await TestHelper.tick();
|
||||||
|
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest);
|
||||||
|
|
||||||
|
mockApiClient.post.mockClear();
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(61000);
|
||||||
|
await TestHelper.tick();
|
||||||
|
expect(mockApiClient.post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {Platform} from 'react-native';
|
||||||
|
|
||||||
|
import {sendPerformanceReport} from '@actions/remote/performance';
|
||||||
|
import DatabaseManager from '@database/manager';
|
||||||
|
import {getConfigValue} from '@queries/servers/system';
|
||||||
|
import {toMilliseconds} from '@utils/datetime';
|
||||||
|
import {logDebug} from '@utils/log';
|
||||||
|
|
||||||
|
const MAX_BATCH_SIZE = 100;
|
||||||
|
const INTERVAL_TIME = toMilliseconds({seconds: 60});
|
||||||
|
|
||||||
|
class Batcher {
|
||||||
|
private batch: PerformanceReportMeasure[] = [];
|
||||||
|
private serverUrl: string;
|
||||||
|
private sendTimeout: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
|
constructor(serverUrl: string) {
|
||||||
|
this.serverUrl = serverUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private started() {
|
||||||
|
return Boolean(this.sendTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimeout() {
|
||||||
|
clearTimeout(this.sendTimeout);
|
||||||
|
this.sendTimeout = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private start() {
|
||||||
|
this.clearTimeout();
|
||||||
|
this.sendTimeout = setTimeout(() => this.sendBatch(), INTERVAL_TIME);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendBatch() {
|
||||||
|
this.clearTimeout();
|
||||||
|
if (this.batch.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toSend = this.getReport();
|
||||||
|
|
||||||
|
// Empty the batch as soon as possible to avoid race conditions
|
||||||
|
this.batch = [];
|
||||||
|
|
||||||
|
const database = DatabaseManager.serverDatabases[this.serverUrl]?.database;
|
||||||
|
if (!database) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientPerformanceSetting = await getConfigValue(database, 'EnableClientMetrics');
|
||||||
|
if (clientPerformanceSetting !== 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendPerformanceReport(this.serverUrl, toSend);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getReport(): PerformanceReport {
|
||||||
|
let start = this.batch[0].timestamp;
|
||||||
|
let end = this.batch[0].timestamp;
|
||||||
|
for (const measure of this.batch) {
|
||||||
|
start = Math.min(start, measure.timestamp);
|
||||||
|
end = Math.max(end, measure.timestamp);
|
||||||
|
}
|
||||||
|
if (start === end) {
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: '0.1.0',
|
||||||
|
labels: {
|
||||||
|
platform: Platform.select({ios: 'ios', default: 'android'}),
|
||||||
|
agent: 'rnapp',
|
||||||
|
},
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
counters: [],
|
||||||
|
histograms: this.batch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public addToBatch(measure: PerformanceReportMeasure) {
|
||||||
|
if (!this.started()) {
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
logDebug('Performance metric:', measure);
|
||||||
|
this.batch.push(measure);
|
||||||
|
if (this.batch.length >= MAX_BATCH_SIZE) {
|
||||||
|
this.sendBatch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public forceSend() {
|
||||||
|
this.sendBatch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Batcher;
|
||||||
16
app/managers/performance_metrics_manager/test_utils.ts
Normal file
16
app/managers/performance_metrics_manager/test_utils.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
export function getBaseReportRequest(start: number, end: number): {body: PerformanceReport; headers: {}} {
|
||||||
|
return {
|
||||||
|
body: {
|
||||||
|
version: '0.1.0',
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
labels: {agent: 'rnapp', platform: 'ios'},
|
||||||
|
histograms: [],
|
||||||
|
counters: [],
|
||||||
|
},
|
||||||
|
headers: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
23
app/managers/performance_metrics_manager/types.d.ts
vendored
Normal file
23
app/managers/performance_metrics_manager/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
type PerformanceReportMeasure = {
|
||||||
|
metric: string;
|
||||||
|
value: number;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PerformanceReport = {
|
||||||
|
version: '0.1.0';
|
||||||
|
|
||||||
|
labels: {
|
||||||
|
platform: PlatformLabel;
|
||||||
|
agent: 'rnapp';
|
||||||
|
};
|
||||||
|
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
|
||||||
|
counters: PerformanceReportMeasure[];
|
||||||
|
histograms: PerformanceReportMeasure[];
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import BottomSheetM, {BottomSheetBackdrop, type BottomSheetBackdropProps, type BottomSheetFooterProps} from '@gorhom/bottom-sheet';
|
import BottomSheetM, {BottomSheetBackdrop, type BottomSheetBackdropProps} from '@gorhom/bottom-sheet';
|
||||||
import React, {type ReactNode, useCallback, useEffect, useMemo, useRef} from 'react';
|
import React, {type ReactNode, useCallback, useEffect, useMemo, useRef} from 'react';
|
||||||
import {DeviceEventEmitter, type Handle, InteractionManager, Keyboard, type StyleProp, View, type ViewStyle} from 'react-native';
|
import {DeviceEventEmitter, type Handle, InteractionManager, Keyboard, type StyleProp, View, type ViewStyle} from 'react-native';
|
||||||
|
|
||||||
|
|
@ -27,7 +27,7 @@ type Props = {
|
||||||
componentId: AvailableScreens;
|
componentId: AvailableScreens;
|
||||||
contentStyle?: StyleProp<ViewStyle>;
|
contentStyle?: StyleProp<ViewStyle>;
|
||||||
initialSnapIndex?: number;
|
initialSnapIndex?: number;
|
||||||
footerComponent?: React.FC<BottomSheetFooterProps>;
|
footerComponent?: React.FC<unknown>;
|
||||||
renderContent: () => ReactNode;
|
renderContent: () => ReactNode;
|
||||||
snapPoints?: Array<string | number>;
|
snapPoints?: Array<string | number>;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
|
|
@ -188,10 +188,12 @@ const BottomSheet = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isTablet) {
|
if (isTablet) {
|
||||||
|
const FooterComponent = footerComponent;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<View style={styles.separator}/>
|
<View style={styles.separator}/>
|
||||||
{renderContainerContent()}
|
{renderContainerContent()}
|
||||||
|
{FooterComponent && (<FooterComponent/>)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import React, {useCallback} from 'react';
|
||||||
import {DeviceEventEmitter, StyleSheet} from 'react-native';
|
import {DeviceEventEmitter, StyleSheet} from 'react-native';
|
||||||
|
|
||||||
import {Events} from '@constants';
|
import {Events} from '@constants';
|
||||||
|
import {useIsTablet} from '@hooks/device';
|
||||||
import BottomSheet from '@screens/bottom_sheet';
|
import BottomSheet from '@screens/bottom_sheet';
|
||||||
|
|
||||||
import Picker from './picker';
|
import Picker from './picker';
|
||||||
|
|
@ -25,6 +26,8 @@ const style = StyleSheet.create({
|
||||||
});
|
});
|
||||||
|
|
||||||
const EmojiPickerScreen = ({closeButtonId, componentId, onEmojiPress}: Props) => {
|
const EmojiPickerScreen = ({closeButtonId, componentId, onEmojiPress}: Props) => {
|
||||||
|
const isTablet = useIsTablet();
|
||||||
|
|
||||||
const handleEmojiPress = useCallback((emoji: string) => {
|
const handleEmojiPress = useCallback((emoji: string) => {
|
||||||
onEmojiPress(emoji);
|
onEmojiPress(emoji);
|
||||||
DeviceEventEmitter.emit(Events.CLOSE_BOTTOM_SHEET);
|
DeviceEventEmitter.emit(Events.CLOSE_BOTTOM_SHEET);
|
||||||
|
|
@ -46,7 +49,7 @@ const EmojiPickerScreen = ({closeButtonId, componentId, onEmojiPress}: Props) =>
|
||||||
componentId={componentId}
|
componentId={componentId}
|
||||||
contentStyle={style.contentStyle}
|
contentStyle={style.contentStyle}
|
||||||
initialSnapIndex={1}
|
initialSnapIndex={1}
|
||||||
footerComponent={PickerFooter}
|
footerComponent={isTablet ? undefined : PickerFooter}
|
||||||
testID='post_options'
|
testID='post_options'
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,19 @@
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import {DeviceEventEmitter} from 'react-native';
|
||||||
|
|
||||||
import {renderWithEverything} from '@test/intl-test-helper';
|
import {Events} from '@constants';
|
||||||
|
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
|
||||||
|
import {renderWithEverything, act, waitFor, screen, waitForElementToBeRemoved} from '@test/intl-test-helper';
|
||||||
import TestHelper from '@test/test_helper';
|
import TestHelper from '@test/test_helper';
|
||||||
|
|
||||||
import Categories from '.';
|
import Categories from '.';
|
||||||
|
|
||||||
import type Database from '@nozbe/watermelondb/Database';
|
import type Database from '@nozbe/watermelondb/Database';
|
||||||
|
|
||||||
|
jest.mock('@managers/performance_metrics_manager');
|
||||||
|
|
||||||
describe('components/channel_list/categories', () => {
|
describe('components/channel_list/categories', () => {
|
||||||
let database: Database;
|
let database: Database;
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
|
@ -17,6 +22,10 @@ describe('components/channel_list/categories', () => {
|
||||||
database = server.database;
|
database = server.database;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await TestHelper.tearDown();
|
||||||
|
});
|
||||||
|
|
||||||
it('render without error', () => {
|
it('render without error', () => {
|
||||||
const wrapper = renderWithEverything(
|
const wrapper = renderWithEverything(
|
||||||
<Categories/>,
|
<Categories/>,
|
||||||
|
|
@ -26,3 +35,37 @@ describe('components/channel_list/categories', () => {
|
||||||
expect(wrapper.toJSON()).toBeTruthy();
|
expect(wrapper.toJSON()).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('performance metrics', () => {
|
||||||
|
let database: Database;
|
||||||
|
const serverUrl = 'http://www.someserverurl.com';
|
||||||
|
beforeAll(async () => {
|
||||||
|
const server = await TestHelper.setupServerDatabase(serverUrl);
|
||||||
|
database = server.database;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await TestHelper.tearDown();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('properly send metric on load', () => {
|
||||||
|
renderWithEverything(<Categories/>, {database, serverUrl});
|
||||||
|
expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledWith('mobile_team_switch', serverUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('properly call again after switching teams', async () => {
|
||||||
|
renderWithEverything(<Categories/>, {database, serverUrl});
|
||||||
|
expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledTimes(1);
|
||||||
|
act(() => {
|
||||||
|
DeviceEventEmitter.emit(Events.TEAM_SWITCH, true);
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(screen.queryByTestId('categories.loading')).toBeVisible());
|
||||||
|
expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledTimes(1);
|
||||||
|
act(() => {
|
||||||
|
DeviceEventEmitter.emit(Events.TEAM_SWITCH, false);
|
||||||
|
});
|
||||||
|
await waitForElementToBeRemoved(() => screen.queryByTestId('categories.loading'));
|
||||||
|
expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledTimes(2);
|
||||||
|
expect(PerformanceMetricsManager.endMetric).toHaveBeenLastCalledWith('mobile_team_switch', serverUrl);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import Loading from '@components/loading';
|
||||||
import {useServerUrl} from '@context/server';
|
import {useServerUrl} from '@context/server';
|
||||||
import {useIsTablet} from '@hooks/device';
|
import {useIsTablet} from '@hooks/device';
|
||||||
import {useTeamSwitch} from '@hooks/team_switch';
|
import {useTeamSwitch} from '@hooks/team_switch';
|
||||||
|
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
|
||||||
|
|
||||||
import CategoryBody from './body';
|
import CategoryBody from './body';
|
||||||
import LoadCategoriesError from './error';
|
import LoadCategoriesError from './error';
|
||||||
|
|
@ -68,6 +69,7 @@ const Categories = ({
|
||||||
const [initiaLoad, setInitialLoad] = useState(!categoriesToShow.length);
|
const [initiaLoad, setInitialLoad] = useState(!categoriesToShow.length);
|
||||||
|
|
||||||
const onChannelSwitch = useCallback(async (c: Channel | ChannelModel) => {
|
const onChannelSwitch = useCallback(async (c: Channel | ChannelModel) => {
|
||||||
|
PerformanceMetricsManager.startMetric('mobile_channel_switch');
|
||||||
switchToChannelById(serverUrl, c.id);
|
switchToChannelById(serverUrl, c.id);
|
||||||
}, [serverUrl]);
|
}, [serverUrl]);
|
||||||
|
|
||||||
|
|
@ -103,6 +105,14 @@ const Categories = ({
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (switchingTeam) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PerformanceMetricsManager.endMetric('mobile_team_switch', serverUrl);
|
||||||
|
}, [switchingTeam]);
|
||||||
|
|
||||||
if (!categories.length) {
|
if (!categories.length) {
|
||||||
return <LoadCategoriesError/>;
|
return <LoadCategoriesError/>;
|
||||||
}
|
}
|
||||||
|
|
@ -139,6 +149,7 @@ const Categories = ({
|
||||||
<Loading
|
<Loading
|
||||||
size='large'
|
size='large'
|
||||||
themeColor='sidebarText'
|
themeColor='sidebarText'
|
||||||
|
testID='categories.loading'
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
51
app/screens/home/channel_list/channel_list.test.tsx
Normal file
51
app/screens/home/channel_list/channel_list.test.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React, {type ComponentProps} from 'react';
|
||||||
|
|
||||||
|
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
|
||||||
|
import {renderWithEverything} from '@test/intl-test-helper';
|
||||||
|
import TestHelper from '@test/test_helper';
|
||||||
|
|
||||||
|
import ChannelListScreen from './channel_list';
|
||||||
|
|
||||||
|
import type {Database} from '@nozbe/watermelondb';
|
||||||
|
|
||||||
|
jest.mock('@managers/performance_metrics_manager');
|
||||||
|
jest.mock('@react-navigation/native', () => ({
|
||||||
|
useIsFocused: () => true,
|
||||||
|
useNavigation: () => ({isFocused: () => true}),
|
||||||
|
useRoute: () => ({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function getBaseProps(): ComponentProps<typeof ChannelListScreen> {
|
||||||
|
return {
|
||||||
|
hasChannels: true,
|
||||||
|
hasCurrentUser: true,
|
||||||
|
hasMoreThanOneTeam: true,
|
||||||
|
hasTeams: true,
|
||||||
|
isCRTEnabled: true,
|
||||||
|
isLicensed: true,
|
||||||
|
launchType: 'normal',
|
||||||
|
showIncomingCalls: true,
|
||||||
|
showToS: false,
|
||||||
|
currentUserId: 'someId',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('performance metrics', () => {
|
||||||
|
let database: Database;
|
||||||
|
const serverUrl = 'http://www.someserverurl.com';
|
||||||
|
beforeAll(async () => {
|
||||||
|
const server = await TestHelper.setupServerDatabase(serverUrl);
|
||||||
|
database = server.database;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finish load on load', () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
const props = getBaseProps();
|
||||||
|
renderWithEverything(<ChannelListScreen {...props}/>, {database, serverUrl});
|
||||||
|
expect(PerformanceMetricsManager.finishLoad).toHaveBeenCalledWith('HOME', serverUrl);
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -18,6 +18,7 @@ import {Navigation as NavigationConstants, Screens} from '@constants';
|
||||||
import {useServerUrl} from '@context/server';
|
import {useServerUrl} from '@context/server';
|
||||||
import {useTheme} from '@context/theme';
|
import {useTheme} from '@context/theme';
|
||||||
import {useIsTablet} from '@hooks/device';
|
import {useIsTablet} from '@hooks/device';
|
||||||
|
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
|
||||||
import {resetToTeams, openToS} from '@screens/navigation';
|
import {resetToTeams, openToS} from '@screens/navigation';
|
||||||
import NavigationStore from '@store/navigation_store';
|
import NavigationStore from '@store/navigation_store';
|
||||||
import {isMainActivity} from '@utils/helpers';
|
import {isMainActivity} from '@utils/helpers';
|
||||||
|
|
@ -169,6 +170,10 @@ const ChannelListScreen = (props: ChannelProps) => {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
PerformanceMetricsManager.finishLoad('HOME', serverUrl);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Animated.View style={top}/>
|
<Animated.View style={top}/>
|
||||||
|
|
|
||||||
|
|
@ -61,34 +61,41 @@ const PostPriorityPickerFooter = ({onCancel, onSubmit, ...props}: Props) => {
|
||||||
const style = getStyleSheet(theme);
|
const style = getStyleSheet(theme);
|
||||||
const isTablet = useIsTablet();
|
const isTablet = useIsTablet();
|
||||||
|
|
||||||
|
const footer = (
|
||||||
|
<View
|
||||||
|
style={[style.container, {
|
||||||
|
paddingBottom: FOOTER_PADDING + Platform.select({ios: (isTablet ? FOOTER_PADDING_BOTTOM_TABLET_ADJUST : 0), default: 0}),
|
||||||
|
}]}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onCancel}
|
||||||
|
style={style.cancelButton}
|
||||||
|
>
|
||||||
|
<FormattedText
|
||||||
|
id='post_priority.picker.cancel'
|
||||||
|
defaultMessage='Cancel'
|
||||||
|
style={style.cancelButtonText}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onSubmit}
|
||||||
|
style={style.applyButton}
|
||||||
|
>
|
||||||
|
<FormattedText
|
||||||
|
id='post_priority.picker.apply'
|
||||||
|
defaultMessage='Apply'
|
||||||
|
style={style.applyButtonText}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isTablet) {
|
||||||
|
return footer;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<BottomSheetFooter {...props}>
|
<BottomSheetFooter {...props}>
|
||||||
<View
|
{footer}
|
||||||
style={[style.container, {
|
|
||||||
paddingBottom: FOOTER_PADDING + Platform.select({ios: (isTablet ? FOOTER_PADDING_BOTTOM_TABLET_ADJUST : 0), default: 0}),
|
|
||||||
}]}
|
|
||||||
>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onCancel}
|
|
||||||
style={style.cancelButton}
|
|
||||||
>
|
|
||||||
<FormattedText
|
|
||||||
id='post_priority.picker.cancel'
|
|
||||||
defaultMessage='Cancel'
|
|
||||||
style={style.cancelButtonText}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onSubmit}
|
|
||||||
style={style.applyButton}
|
|
||||||
>
|
|
||||||
<FormattedText
|
|
||||||
id='post_priority.picker.apply'
|
|
||||||
defaultMessage='Apply'
|
|
||||||
style={style.applyButtonText}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</BottomSheetFooter>
|
</BottomSheetFooter>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {screen} from '@testing-library/react-native';
|
||||||
|
import React, {type ComponentProps} from 'react';
|
||||||
|
|
||||||
|
import {useIsTablet} from '@hooks/device';
|
||||||
|
import {renderWithIntl} from '@test/intl-test-helper';
|
||||||
|
|
||||||
|
import PostPriorityPicker from './post_priority_picker';
|
||||||
|
|
||||||
|
jest.mock('@hooks/device');
|
||||||
|
const mockedIsTablet = jest.mocked(useIsTablet);
|
||||||
|
|
||||||
|
function getBaseProps(): ComponentProps<typeof PostPriorityPicker> {
|
||||||
|
return {
|
||||||
|
closeButtonId: '',
|
||||||
|
componentId: 'BottomSheet',
|
||||||
|
isPersistenNotificationsEnabled: true,
|
||||||
|
isPostAcknowledgementEnabled: true,
|
||||||
|
persistentNotificationInterval: 0,
|
||||||
|
postPriority: {priority: ''},
|
||||||
|
updatePostPriority: jest.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
describe('post_priority_picker', () => {
|
||||||
|
it('correctly shows the apply and cancel buttons on mobile', async () => {
|
||||||
|
mockedIsTablet.mockReturnValue(false);
|
||||||
|
const props = getBaseProps();
|
||||||
|
renderWithIntl(<PostPriorityPicker {...props}/>);
|
||||||
|
expect(await screen.findByText('Apply')).toBeVisible();
|
||||||
|
expect(await screen.findByText('Cancel')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly shows the apply and cancel buttons on tablet', async () => {
|
||||||
|
mockedIsTablet.mockReturnValue(true);
|
||||||
|
const props = getBaseProps();
|
||||||
|
renderWithIntl(<PostPriorityPicker {...props}/>);
|
||||||
|
expect(await screen.findByText('Apply')).toBeVisible();
|
||||||
|
expect(await screen.findByText('Cancel')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -16,6 +16,10 @@
|
||||||
|
|
||||||
NSString *const ReplyActionID = @"REPLY_ACTION";
|
NSString *const ReplyActionID = @"REPLY_ACTION";
|
||||||
|
|
||||||
|
typedef void (*SendReplyCompletionHandlerIMP)(id, SEL, UNNotificationResponse *, void (^)(void));
|
||||||
|
static SendReplyCompletionHandlerIMP originalSendReplyCompletionHandlerImplementation = NULL;
|
||||||
|
|
||||||
|
|
||||||
@implementation RNNotificationEventHandler (HandleReplyAction)
|
@implementation RNNotificationEventHandler (HandleReplyAction)
|
||||||
|
|
||||||
- (RNNotificationCenter *)notificationCenter{
|
- (RNNotificationCenter *)notificationCenter{
|
||||||
|
|
@ -37,7 +41,16 @@ NSString *const ReplyActionID = @"REPLY_ACTION";
|
||||||
Method originalMethod = class_getInstanceMethod(class, originalSelector);
|
Method originalMethod = class_getInstanceMethod(class, originalSelector);
|
||||||
Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
|
Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
|
||||||
|
|
||||||
method_exchangeImplementations(originalMethod, swizzledMethod);
|
// Get the implementation of the swizzled method
|
||||||
|
IMP swizzledImplementation = method_getImplementation(swizzledMethod);
|
||||||
|
|
||||||
|
// Get the original implementation
|
||||||
|
IMP originalImplementation = method_getImplementation(originalMethod);
|
||||||
|
|
||||||
|
// Set the original method's implementation to the swizzled method's implementation
|
||||||
|
method_setImplementation(originalMethod, swizzledImplementation);
|
||||||
|
|
||||||
|
originalSendReplyCompletionHandlerImplementation = (SendReplyCompletionHandlerIMP)originalImplementation;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,7 +153,7 @@ NSString *const ReplyActionID = @"REPLY_ACTION";
|
||||||
if ([response.actionIdentifier isEqualToString:ReplyActionID]) {
|
if ([response.actionIdentifier isEqualToString:ReplyActionID]) {
|
||||||
[self sendReply:response completionHandler:completionHandler];
|
[self sendReply:response completionHandler:completionHandler];
|
||||||
} else {
|
} else {
|
||||||
[self handleReplyAction_didReceiveNotificationResponse:response completionHandler:completionHandler];
|
originalSendReplyCompletionHandlerImplementation(self, @selector(sendReply:completionHandler:), response, completionHandler);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
@import react_native_network_client;
|
@import react_native_network_client;
|
||||||
#import <objc/runtime.h>
|
#import <objc/runtime.h>
|
||||||
|
|
||||||
|
typedef id (*InitWithRequestInSessionOptionsContextIMP)(id, SEL, NSURLRequest *, NSURLSession *, SDWebImageDownloaderOptions *, id);
|
||||||
|
typedef void (*URLSessionTaskDidReceiveChallengeIMP)(id, SEL, NSURLSession *, NSURLSessionTask *, NSURLAuthenticationChallenge *, void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential *));
|
||||||
|
|
||||||
@implementation SDWebImageDownloaderOperation (Swizzle)
|
@implementation SDWebImageDownloaderOperation (Swizzle)
|
||||||
|
|
||||||
+ (void) load {
|
+ (void) load {
|
||||||
|
|
@ -18,6 +21,9 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static InitWithRequestInSessionOptionsContextIMP originalInitWithRequestInSessionOptionsContextImplementation = NULL;
|
||||||
|
static URLSessionTaskDidReceiveChallengeIMP originalURLSessionTaskDidReceiveChallengeImplementation = NULL;
|
||||||
|
|
||||||
+ (void) swizzleInitMethod {
|
+ (void) swizzleInitMethod {
|
||||||
Class class = [self class];
|
Class class = [self class];
|
||||||
|
|
||||||
|
|
@ -27,7 +33,16 @@
|
||||||
Method originalMethod = class_getInstanceMethod(class, originalSelector);
|
Method originalMethod = class_getInstanceMethod(class, originalSelector);
|
||||||
Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
|
Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
|
||||||
|
|
||||||
method_exchangeImplementations(originalMethod, swizzledMethod);
|
// Get the implementation of the swizzled method
|
||||||
|
IMP swizzledImplementation = method_getImplementation(swizzledMethod);
|
||||||
|
|
||||||
|
// Get the original implementation
|
||||||
|
IMP originalImplementation = method_getImplementation(originalMethod);
|
||||||
|
|
||||||
|
// Set the original method's implementation to the swizzled method's implementation
|
||||||
|
method_setImplementation(originalMethod, swizzledImplementation);
|
||||||
|
|
||||||
|
originalInitWithRequestInSessionOptionsContextImplementation = (InitWithRequestInSessionOptionsContextIMP)originalImplementation;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,7 +55,16 @@
|
||||||
Method originalMethod = class_getInstanceMethod(class, originalSelector);
|
Method originalMethod = class_getInstanceMethod(class, originalSelector);
|
||||||
Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
|
Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
|
||||||
|
|
||||||
method_exchangeImplementations(originalMethod, swizzledMethod);
|
// Get the implementation of the swizzled method
|
||||||
|
IMP swizzledImplementation = method_getImplementation(swizzledMethod);
|
||||||
|
|
||||||
|
// Get the original implementation
|
||||||
|
IMP originalImplementation = method_getImplementation(originalMethod);
|
||||||
|
|
||||||
|
// Set the original method's implementation to the swizzled method's implementation
|
||||||
|
method_setImplementation(originalMethod, swizzledImplementation);
|
||||||
|
|
||||||
|
originalURLSessionTaskDidReceiveChallengeImplementation = (URLSessionTaskDidReceiveChallengeIMP)originalImplementation;
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma mark - Method Swizzling
|
#pragma mark - Method Swizzling
|
||||||
|
|
@ -56,14 +80,14 @@
|
||||||
// our BearerAuthenticationAdapter.
|
// our BearerAuthenticationAdapter.
|
||||||
NSURLSessionConfiguration *configuration = [nativeClientSessionManager getSessionConfigurationFor:sessionBaseUrl];
|
NSURLSessionConfiguration *configuration = [nativeClientSessionManager getSessionConfigurationFor:sessionBaseUrl];
|
||||||
NSURLSession *newSession = [NSURLSession sessionWithConfiguration:configuration
|
NSURLSession *newSession = [NSURLSession sessionWithConfiguration:configuration
|
||||||
delegate:self
|
delegate:self
|
||||||
delegateQueue:session.delegateQueue];
|
delegateQueue:session.delegateQueue];
|
||||||
NSURLRequest *authorizedRequest = [BearerAuthenticationAdapter addAuthorizationBearerTokenTo:request withSessionBaseUrlString:sessionBaseUrl.absoluteString];
|
NSURLRequest *authorizedRequest = [BearerAuthenticationAdapter addAuthorizationBearerTokenTo:request withSessionBaseUrlString:sessionBaseUrl.absoluteString];
|
||||||
|
|
||||||
return [self swizzled_initWithRequest:authorizedRequest inSession:newSession options:options context:context];
|
return originalInitWithRequestInSessionOptionsContextImplementation(self, @selector(initWithRequest:inSession:options:context:), authorizedRequest, session, &options, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [self swizzled_initWithRequest:request inSession:session options:options context:context];
|
return originalInitWithRequestInSessionOptionsContextImplementation(self, @selector(initWithRequest:inSession:options:context:), request, session, &options, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -95,7 +119,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
[self swizzled_URLSession:session task:task didReceiveChallenge:challenge completionHandler:completionHandler];
|
originalURLSessionTaskDidReceiveChallengeImplementation(self, @selector(URLSession:task:didReceiveChallenge:completionHandler:), session, task, challenge, completionHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
|
||||||
|
|
@ -952,6 +952,8 @@ PODS:
|
||||||
- react-native-paste-input (0.7.1):
|
- react-native-paste-input (0.7.1):
|
||||||
- React-Core
|
- React-Core
|
||||||
- Swime (= 3.0.6)
|
- Swime (= 3.0.6)
|
||||||
|
- react-native-performance (5.1.2):
|
||||||
|
- React-Core
|
||||||
- react-native-safe-area-context (4.9.0):
|
- react-native-safe-area-context (4.9.0):
|
||||||
- React-Core
|
- React-Core
|
||||||
- react-native-video (5.2.1):
|
- react-native-video (5.2.1):
|
||||||
|
|
@ -1267,6 +1269,7 @@ DEPENDENCIES:
|
||||||
- "react-native-network-client (from `../node_modules/@mattermost/react-native-network-client`)"
|
- "react-native-network-client (from `../node_modules/@mattermost/react-native-network-client`)"
|
||||||
- react-native-notifications (from `../node_modules/react-native-notifications`)
|
- react-native-notifications (from `../node_modules/react-native-notifications`)
|
||||||
- "react-native-paste-input (from `../node_modules/@mattermost/react-native-paste-input`)"
|
- "react-native-paste-input (from `../node_modules/@mattermost/react-native-paste-input`)"
|
||||||
|
- react-native-performance (from `../node_modules/react-native-performance`)
|
||||||
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
|
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
|
||||||
- react-native-video (from `../node_modules/react-native-video`)
|
- react-native-video (from `../node_modules/react-native-video`)
|
||||||
- react-native-webrtc (from `../node_modules/react-native-webrtc`)
|
- react-native-webrtc (from `../node_modules/react-native-webrtc`)
|
||||||
|
|
@ -1424,6 +1427,8 @@ EXTERNAL SOURCES:
|
||||||
:path: "../node_modules/react-native-notifications"
|
:path: "../node_modules/react-native-notifications"
|
||||||
react-native-paste-input:
|
react-native-paste-input:
|
||||||
:path: "../node_modules/@mattermost/react-native-paste-input"
|
:path: "../node_modules/@mattermost/react-native-paste-input"
|
||||||
|
react-native-performance:
|
||||||
|
:path: "../node_modules/react-native-performance"
|
||||||
react-native-safe-area-context:
|
react-native-safe-area-context:
|
||||||
:path: "../node_modules/react-native-safe-area-context"
|
:path: "../node_modules/react-native-safe-area-context"
|
||||||
react-native-video:
|
react-native-video:
|
||||||
|
|
@ -1581,6 +1586,7 @@ SPEC CHECKSUMS:
|
||||||
react-native-network-client: a7e0e465f0de5ea75cef5c557df0d9dc0adbf6a9
|
react-native-network-client: a7e0e465f0de5ea75cef5c557df0d9dc0adbf6a9
|
||||||
react-native-notifications: 4601a5a8db4ced6ae7cfc43b44d35fe437ac50c4
|
react-native-notifications: 4601a5a8db4ced6ae7cfc43b44d35fe437ac50c4
|
||||||
react-native-paste-input: d2136a8269eb8ad57d81407ee2b8a646f738a694
|
react-native-paste-input: d2136a8269eb8ad57d81407ee2b8a646f738a694
|
||||||
|
react-native-performance: ff93f8af3b2ee9519fd7879896aa9b8b8272691d
|
||||||
react-native-safe-area-context: b97eb6f9e3b7f437806c2ce5983f479f8eb5de4b
|
react-native-safe-area-context: b97eb6f9e3b7f437806c2ce5983f479f8eb5de4b
|
||||||
react-native-video: c26780b224543c62d5e1b2a7244a5cd1b50e8253
|
react-native-video: c26780b224543c62d5e1b2a7244a5cd1b50e8253
|
||||||
react-native-webrtc: 255a1172fd31525b952b36aef7b8e9a41de325e5
|
react-native-webrtc: 255a1172fd31525b952b36aef7b8e9a41de325e5
|
||||||
|
|
|
||||||
9
package-lock.json
generated
9
package-lock.json
generated
|
|
@ -79,6 +79,7 @@
|
||||||
"react-native-math-view": "3.9.5",
|
"react-native-math-view": "3.9.5",
|
||||||
"react-native-navigation": "7.39.1",
|
"react-native-navigation": "7.39.1",
|
||||||
"react-native-notifications": "5.1.0",
|
"react-native-notifications": "5.1.0",
|
||||||
|
"react-native-performance": "5.1.2",
|
||||||
"react-native-permissions": "4.1.5",
|
"react-native-permissions": "4.1.5",
|
||||||
"react-native-reanimated": "3.8.1",
|
"react-native-reanimated": "3.8.1",
|
||||||
"react-native-safe-area-context": "4.9.0",
|
"react-native-safe-area-context": "4.9.0",
|
||||||
|
|
@ -17258,6 +17259,14 @@
|
||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-performance": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-performance/-/react-native-performance-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-l5JOJphNzox9a9icL3T6O/gEqZuqWqcbejW04WPa10m0UanBdIYrNkPFl48B3ivWw3MabpjB6GiDYv7old9/fw==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-native-permissions": {
|
"node_modules/react-native-permissions": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-4.1.5.tgz",
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@
|
||||||
"react-native-math-view": "3.9.5",
|
"react-native-math-view": "3.9.5",
|
||||||
"react-native-navigation": "7.39.1",
|
"react-native-navigation": "7.39.1",
|
||||||
"react-native-notifications": "5.1.0",
|
"react-native-notifications": "5.1.0",
|
||||||
|
"react-native-performance": "5.1.2",
|
||||||
"react-native-permissions": "4.1.5",
|
"react-native-permissions": "4.1.5",
|
||||||
"react-native-reanimated": "3.8.1",
|
"react-native-reanimated": "3.8.1",
|
||||||
"react-native-safe-area-context": "4.9.0",
|
"react-native-safe-area-context": "4.9.0",
|
||||||
|
|
|
||||||
|
|
@ -370,7 +370,7 @@ index 027c366..5807e79 100644
|
||||||
unsafeExecute(work: UnsafeExecuteOperations, callback: ResultCallback<void>): void
|
unsafeExecute(work: UnsafeExecuteOperations, callback: ResultCallback<void>): void
|
||||||
|
|
||||||
diff --git a/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabase.java b/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabase.java
|
diff --git a/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabase.java b/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabase.java
|
||||||
index 2f170e0..bac0352 100644
|
index 2f170e0..bd87f92 100644
|
||||||
--- a/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabase.java
|
--- a/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabase.java
|
||||||
+++ b/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabase.java
|
+++ b/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabase.java
|
||||||
@@ -11,6 +11,8 @@ import java.util.Arrays;
|
@@ -11,6 +11,8 @@ import java.util.Arrays;
|
||||||
|
|
@ -414,17 +414,19 @@ index 2f170e0..bac0352 100644
|
||||||
} else {
|
} else {
|
||||||
// On some systems there is some kind of lock on `/databases` folder ¯\_(ツ)_/¯
|
// On some systems there is some kind of lock on `/databases` folder ¯\_(ツ)_/¯
|
||||||
path = context.getDatabasePath("" + name + ".db").getPath().replace("/databases", "");
|
path = context.getDatabasePath("" + name + ".db").getPath().replace("/databases", "");
|
||||||
@@ -172,6 +190,10 @@ public class WMDatabase {
|
@@ -172,7 +190,11 @@ public class WMDatabase {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- interface TransactionFunction {
|
||||||
+ public void unsafeVacuum() {
|
+ public void unsafeVacuum() {
|
||||||
+ execute("vacuum");
|
+ execute("vacuum");
|
||||||
+ }
|
+ }
|
||||||
+
|
+
|
||||||
interface TransactionFunction {
|
+ public interface TransactionFunction {
|
||||||
void applyTransactionFunction();
|
void applyTransactionFunction();
|
||||||
}
|
}
|
||||||
|
|
||||||
diff --git a/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabaseBridge.java b/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabaseBridge.java
|
diff --git a/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabaseBridge.java b/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabaseBridge.java
|
||||||
index 117b2bc..57e4abb 100644
|
index 117b2bc..57e4abb 100644
|
||||||
--- a/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabaseBridge.java
|
--- a/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabaseBridge.java
|
||||||
|
|
|
||||||
13
patches/react-native-exception-handler+2.10.10.patch
Normal file
13
patches/react-native-exception-handler+2.10.10.patch
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
diff --git a/node_modules/react-native-exception-handler/android/src/main/AndroidManifest.xml b/node_modules/react-native-exception-handler/android/src/main/AndroidManifest.xml
|
||||||
|
index 58dfc7b..47c9954 100644
|
||||||
|
--- a/node_modules/react-native-exception-handler/android/src/main/AndroidManifest.xml
|
||||||
|
+++ b/node_modules/react-native-exception-handler/android/src/main/AndroidManifest.xml
|
||||||
|
@@ -3,7 +3,7 @@
|
||||||
|
package="com.masteratul.exceptionhandler">
|
||||||
|
|
||||||
|
<application>
|
||||||
|
- <activity android:name=".DefaultErrorScreen">
|
||||||
|
+ <activity android:name=".DefaultErrorScreen" android:exported="false">
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
|
@ -16,10 +16,10 @@ index 30bb01c..bba788d 100644
|
||||||
events "PASSED", "SKIPPED", "FAILED", "standardOut", "standardError"
|
events "PASSED", "SKIPPED", "FAILED", "standardOut", "standardError"
|
||||||
}
|
}
|
||||||
diff --git a/node_modules/react-native-notifications/lib/android/app/src/main/AndroidManifest.xml b/node_modules/react-native-notifications/lib/android/app/src/main/AndroidManifest.xml
|
diff --git a/node_modules/react-native-notifications/lib/android/app/src/main/AndroidManifest.xml b/node_modules/react-native-notifications/lib/android/app/src/main/AndroidManifest.xml
|
||||||
index 24cd226..4bfacba 100644
|
index 24cd226..3aa1728 100644
|
||||||
--- a/node_modules/react-native-notifications/lib/android/app/src/main/AndroidManifest.xml
|
--- a/node_modules/react-native-notifications/lib/android/app/src/main/AndroidManifest.xml
|
||||||
+++ b/node_modules/react-native-notifications/lib/android/app/src/main/AndroidManifest.xml
|
+++ b/node_modules/react-native-notifications/lib/android/app/src/main/AndroidManifest.xml
|
||||||
@@ -3,6 +3,7 @@
|
@@ -3,19 +3,19 @@
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="com.wix.reactnativenotifications">
|
package="com.wix.reactnativenotifications">
|
||||||
|
|
||||||
|
|
@ -27,7 +27,22 @@ index 24cd226..4bfacba 100644
|
||||||
<application>
|
<application>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -23,6 +24,9 @@
|
A proxy-service that gives the library an opportunity to do some work before launching/resuming the actual application task.
|
||||||
|
-->
|
||||||
|
- <service android:name=".core.ProxyService"/>
|
||||||
|
+ <service android:name=".core.ProxyService" android:exported="false"/>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".fcm.FcmInstanceIdListenerService"
|
||||||
|
- android:exported="true">
|
||||||
|
+ android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
- <action android:name="com.google.firebase.INSTANCE_ID_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
@@ -23,6 +23,9 @@
|
||||||
android:name=".fcm.FcmInstanceIdRefreshHandlerService"
|
android:name=".fcm.FcmInstanceIdRefreshHandlerService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:permission="android.permission.BIND_JOB_SERVICE" />
|
android:permission="android.permission.BIND_JOB_SERVICE" />
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {DatabaseProvider} from '@nozbe/watermelondb/react';
|
import {DatabaseProvider} from '@nozbe/watermelondb/react';
|
||||||
import {render} from '@testing-library/react-native';
|
import {render, type RenderOptions} from '@testing-library/react-native';
|
||||||
import React, {type ReactElement} from 'react';
|
import React, {type ReactElement} from 'react';
|
||||||
import {IntlProvider} from 'react-intl';
|
import {IntlProvider} from 'react-intl';
|
||||||
import {SafeAreaProvider} from 'react-native-safe-area-context';
|
import {SafeAreaProvider} from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import ServerUrlProvider from '@context/server';
|
||||||
import {ThemeContext, getDefaultThemeByAppearance} from '@context/theme';
|
import {ThemeContext, getDefaultThemeByAppearance} from '@context/theme';
|
||||||
import {getTranslations} from '@i18n';
|
import {getTranslations} from '@i18n';
|
||||||
|
|
||||||
|
|
@ -48,13 +49,13 @@ export function renderWithIntlAndTheme(ui: ReactElement, {locale = 'en', ...rend
|
||||||
return render(ui, {wrapper: Wrapper, ...renderOptions});
|
return render(ui, {wrapper: Wrapper, ...renderOptions});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderWithEverything(ui: ReactElement, {locale = 'en', database, ...renderOptions}: {locale?: string; database?: Database; renderOptions?: any} = {}) {
|
export function renderWithEverything(ui: ReactElement, {locale = 'en', database, serverUrl, ...renderOptions}: {locale?: string; database?: Database; serverUrl?: string; renderOptions?: RenderOptions} = {}) {
|
||||||
function Wrapper({children}: {children: ReactElement}) {
|
function Wrapper({children}: {children: ReactElement}) {
|
||||||
if (!database) {
|
if (!database) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const wrapper = (
|
||||||
<DatabaseProvider database={database}>
|
<DatabaseProvider database={database}>
|
||||||
<IntlProvider
|
<IntlProvider
|
||||||
locale={locale}
|
locale={locale}
|
||||||
|
|
@ -68,6 +69,16 @@ export function renderWithEverything(ui: ReactElement, {locale = 'en', database,
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</DatabaseProvider>
|
</DatabaseProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (serverUrl) {
|
||||||
|
return (
|
||||||
|
<ServerUrlProvider server={{displayName: serverUrl, url: serverUrl}}>
|
||||||
|
{wrapper}
|
||||||
|
</ServerUrlProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(ui, {wrapper: Wrapper, ...renderOptions});
|
return render(ui, {wrapper: Wrapper, ...renderOptions});
|
||||||
|
|
|
||||||
11
test/mock_api_client.ts
Normal file
11
test/mock_api_client.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import type {RequestOptions} from '@mattermost/react-native-network-client';
|
||||||
|
|
||||||
|
export const mockApiClient = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
get: jest.fn((url: string, options?: RequestOptions) => ({status: 200, ok: true})),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
post: jest.fn((url: string, options?: RequestOptions) => ({status: 200, ok: true})),
|
||||||
|
};
|
||||||
|
|
@ -5,12 +5,17 @@
|
||||||
|
|
||||||
import {setGenerator} from '@nozbe/watermelondb/utils/common/randomId';
|
import {setGenerator} from '@nozbe/watermelondb/utils/common/randomId';
|
||||||
import * as ReactNative from 'react-native';
|
import * as ReactNative from 'react-native';
|
||||||
import 'react-native-gesture-handler/jestSetup';
|
|
||||||
import mockSafeAreaContext from 'react-native-safe-area-context/jest/mock';
|
import mockSafeAreaContext from 'react-native-safe-area-context/jest/mock';
|
||||||
import {v4 as uuidv4} from 'uuid';
|
import {v4 as uuidv4} from 'uuid';
|
||||||
|
|
||||||
|
import {mockApiClient} from './mock_api_client';
|
||||||
|
|
||||||
|
import type {RequestOptions} from '@mattermost/react-native-network-client';
|
||||||
import type {ReadDirItem, StatResult} from 'react-native-fs';
|
import type {ReadDirItem, StatResult} from 'react-native-fs';
|
||||||
|
|
||||||
|
import 'react-native-gesture-handler/jestSetup';
|
||||||
|
import '@testing-library/react-native/extend-expect';
|
||||||
|
|
||||||
// @ts-expect-error Promise does not exists in global
|
// @ts-expect-error Promise does not exists in global
|
||||||
global.Promise = jest.requireActual('promise');
|
global.Promise = jest.requireActual('promise');
|
||||||
|
|
||||||
|
|
@ -184,17 +189,11 @@ jest.doMock('react-native', () => {
|
||||||
|
|
||||||
jest.mock('react-native-vector-icons', () => {
|
jest.mock('react-native-vector-icons', () => {
|
||||||
const React = jest.requireActual('react');
|
const React = jest.requireActual('react');
|
||||||
const PropTypes = jest.requireActual('prop-types');
|
|
||||||
class CompassIcon extends React.PureComponent {
|
class CompassIcon extends React.PureComponent {
|
||||||
render() {
|
render() {
|
||||||
return React.createElement('Icon', this.props);
|
return React.createElement('Icon', this.props);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CompassIcon.propTypes = {
|
|
||||||
name: PropTypes.string,
|
|
||||||
size: PropTypes.number,
|
|
||||||
style: PropTypes.oneOfType([PropTypes.array, PropTypes.number, PropTypes.object]),
|
|
||||||
};
|
|
||||||
CompassIcon.getImageSource = jest.fn().mockResolvedValue({});
|
CompassIcon.getImageSource = jest.fn().mockResolvedValue({});
|
||||||
return {
|
return {
|
||||||
createIconSet: () => CompassIcon,
|
createIconSet: () => CompassIcon,
|
||||||
|
|
@ -248,12 +247,14 @@ jest.mock('../node_modules/react-native/Libraries/EventEmitter/NativeEventEmitte
|
||||||
|
|
||||||
jest.mock('react-native-device-info', () => {
|
jest.mock('react-native-device-info', () => {
|
||||||
return {
|
return {
|
||||||
getVersion: () => '0.0.0',
|
getVersion: jest.fn(() => '0.0.0'),
|
||||||
getBuildNumber: () => '0',
|
getBuildNumber: jest.fn(() => '0'),
|
||||||
getModel: () => 'iPhone X',
|
getModel: jest.fn(() => 'iPhone X'),
|
||||||
hasNotch: () => true,
|
hasNotch: jest.fn(() => true),
|
||||||
isTablet: () => false,
|
isTablet: jest.fn(() => false),
|
||||||
getApplicationName: () => 'Mattermost',
|
getApplicationName: jest.fn(() => 'Mattermost'),
|
||||||
|
getSystemName: jest.fn(() => 'ios'),
|
||||||
|
getSystemVersion: jest.fn(() => '0.0.0'),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -365,6 +366,8 @@ jest.mock('@screens/navigation', () => ({
|
||||||
popToRoot: jest.fn(() => Promise.resolve()),
|
popToRoot: jest.fn(() => Promise.resolve()),
|
||||||
dismissModal: jest.fn(() => Promise.resolve()),
|
dismissModal: jest.fn(() => Promise.resolve()),
|
||||||
dismissAllModals: jest.fn(() => Promise.resolve()),
|
dismissAllModals: jest.fn(() => Promise.resolve()),
|
||||||
|
dismissAllModalsAndPopToScreen: jest.fn(),
|
||||||
|
dismissAllModalsAndPopToRoot: jest.fn(),
|
||||||
dismissOverlay: jest.fn(() => Promise.resolve()),
|
dismissOverlay: jest.fn(() => Promise.resolve()),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -384,12 +387,44 @@ jest.mock('@mattermost/react-native-emm', () => ({
|
||||||
useManagedConfig: () => ({}),
|
useManagedConfig: () => ({}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock('@react-native-clipboard/clipboard', () => ({}));
|
||||||
|
|
||||||
|
jest.mock('react-native-document-picker', () => ({}));
|
||||||
|
|
||||||
|
jest.mock('@mattermost/react-native-network-client', () => ({
|
||||||
|
getOrCreateAPIClient: (serverUrl: string) => ({client: {
|
||||||
|
baseUrl: serverUrl,
|
||||||
|
get: (url: string, options?: RequestOptions) => mockApiClient.get(`${serverUrl}${url}`, options),
|
||||||
|
post: (url: string, options?: RequestOptions) => mockApiClient.post(`${serverUrl}${url}`, options),
|
||||||
|
invalidate: jest.fn(),
|
||||||
|
}}),
|
||||||
|
RetryTypes: {
|
||||||
|
EXPONENTIAL_RETRY: 'exponential',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
jest.mock('react-native-safe-area-context', () => mockSafeAreaContext);
|
jest.mock('react-native-safe-area-context', () => mockSafeAreaContext);
|
||||||
|
|
||||||
jest.mock('react-native-reanimated', () => require('react-native-reanimated/mock'));
|
jest.mock('react-native-reanimated', () => require('react-native-reanimated/mock'));
|
||||||
jest.mock('react-native-permissions', () => require('react-native-permissions/mock'));
|
jest.mock('react-native-permissions', () => require('react-native-permissions/mock'));
|
||||||
|
|
||||||
declare const global: {requestAnimationFrame: (callback: any) => void};
|
jest.mock('react-native-haptic-feedback', () => {
|
||||||
|
const RNHF = jest.requireActual('react-native-haptic-feedback');
|
||||||
|
return {
|
||||||
|
...RNHF,
|
||||||
|
trigger: () => '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
declare const global: {
|
||||||
|
requestAnimationFrame: (callback: () => void) => void;
|
||||||
|
performance: {
|
||||||
|
now: () => number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
global.requestAnimationFrame = (callback) => {
|
global.requestAnimationFrame = (callback) => {
|
||||||
setTimeout(callback, 0);
|
setTimeout(callback, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
global.performance.now = () => Date.now();
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import nock from 'nock';
|
||||||
|
|
||||||
import Config from '@assets/config.json';
|
import Config from '@assets/config.json';
|
||||||
import {Client} from '@client/rest';
|
import {Client} from '@client/rest';
|
||||||
|
import {ActionType} from '@constants';
|
||||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||||
import {PUSH_PROXY_STATUS_VERIFIED} from '@constants/push_proxy';
|
import {PUSH_PROXY_STATUS_VERIFIED} from '@constants/push_proxy';
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
|
|
@ -18,7 +19,6 @@ import {generateId} from '@utils/general';
|
||||||
|
|
||||||
import type {APIClientInterface} from '@mattermost/react-native-network-client';
|
import type {APIClientInterface} from '@mattermost/react-native-network-client';
|
||||||
|
|
||||||
const PASSWORD = 'password1';
|
|
||||||
const DEFAULT_LOCALE = 'en';
|
const DEFAULT_LOCALE = 'en';
|
||||||
|
|
||||||
class TestHelper {
|
class TestHelper {
|
||||||
|
|
@ -51,8 +51,8 @@ class TestHelper {
|
||||||
this.basicRoles = null;
|
this.basicRoles = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
setupServerDatabase = async () => {
|
setupServerDatabase = async (url?: string) => {
|
||||||
const serverUrl = 'https://appv1.mattermost.com';
|
const serverUrl = url || 'https://appv1.mattermost.com';
|
||||||
await DatabaseManager.init([serverUrl]);
|
await DatabaseManager.init([serverUrl]);
|
||||||
const {database, operator} = DatabaseManager.serverDatabases[serverUrl]!;
|
const {database, operator} = DatabaseManager.serverDatabases[serverUrl]!;
|
||||||
|
|
||||||
|
|
@ -112,6 +112,13 @@ class TestHelper {
|
||||||
systems: [{id: SYSTEM_IDENTIFIERS.PUSH_VERIFICATION_STATUS, value: PUSH_PROXY_STATUS_VERIFIED}],
|
systems: [{id: SYSTEM_IDENTIFIERS.PUSH_VERIFICATION_STATUS, value: PUSH_PROXY_STATUS_VERIFIED}],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await operator.handlePosts({
|
||||||
|
actionType: ActionType.POSTS.RECEIVED_NEW,
|
||||||
|
order: [this.basicPost!.id],
|
||||||
|
posts: [this.basicPost!],
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
});
|
||||||
|
|
||||||
return {database, operator};
|
return {database, operator};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -291,7 +298,7 @@ class TestHelper {
|
||||||
return 'success' + this.generateId() + '@simulator.amazonses.com';
|
return 'success' + this.generateId() + '@simulator.amazonses.com';
|
||||||
};
|
};
|
||||||
|
|
||||||
fakePost = (channelId: string) => {
|
fakePost = (channelId: string, userId?: string): Post => {
|
||||||
const time = Date.now();
|
const time = Date.now();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -301,6 +308,17 @@ class TestHelper {
|
||||||
update_at: time,
|
update_at: time,
|
||||||
message: `Unit Test ${this.generateId()}`,
|
message: `Unit Test ${this.generateId()}`,
|
||||||
type: '',
|
type: '',
|
||||||
|
delete_at: 0,
|
||||||
|
edit_at: 0,
|
||||||
|
hashtags: '',
|
||||||
|
is_pinned: false,
|
||||||
|
metadata: {},
|
||||||
|
original_id: '',
|
||||||
|
pending_post_id: '',
|
||||||
|
props: {},
|
||||||
|
reply_count: 0,
|
||||||
|
root_id: '',
|
||||||
|
user_id: userId || this.generateId(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -314,7 +332,7 @@ class TestHelper {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
fakeTeam = () => {
|
fakeTeam = (): Team => {
|
||||||
const name = this.generateId();
|
const name = this.generateId();
|
||||||
let inviteId = this.generateId();
|
let inviteId = this.generateId();
|
||||||
if (inviteId.length > 32) {
|
if (inviteId.length > 32) {
|
||||||
|
|
@ -322,6 +340,7 @@ class TestHelper {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
id: this.generateId(),
|
||||||
name,
|
name,
|
||||||
display_name: `Unit Test ${name}`,
|
display_name: `Unit Test ${name}`,
|
||||||
type: 'O' as const,
|
type: 'O' as const,
|
||||||
|
|
@ -334,6 +353,9 @@ class TestHelper {
|
||||||
allow_open_invite: true,
|
allow_open_invite: true,
|
||||||
group_constrained: false,
|
group_constrained: false,
|
||||||
last_team_icon_update: 0,
|
last_team_icon_update: 0,
|
||||||
|
create_at: 0,
|
||||||
|
delete_at: 0,
|
||||||
|
update_at: 0,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -361,11 +383,9 @@ class TestHelper {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
fakeUser = () => {
|
fakeUser = (): UserProfile => {
|
||||||
return {
|
return {
|
||||||
email: this.fakeEmail(),
|
email: this.fakeEmail(),
|
||||||
allow_marketing: true,
|
|
||||||
password: PASSWORD,
|
|
||||||
locale: DEFAULT_LOCALE,
|
locale: DEFAULT_LOCALE,
|
||||||
username: this.generateId(),
|
username: this.generateId(),
|
||||||
first_name: this.generateId(),
|
first_name: this.generateId(),
|
||||||
|
|
@ -373,6 +393,27 @@ class TestHelper {
|
||||||
create_at: Date.now(),
|
create_at: Date.now(),
|
||||||
delete_at: 0,
|
delete_at: 0,
|
||||||
roles: 'system_user',
|
roles: 'system_user',
|
||||||
|
auth_service: '',
|
||||||
|
id: this.generateId(),
|
||||||
|
nickname: '',
|
||||||
|
notify_props: this.fakeNotifyProps(),
|
||||||
|
position: '',
|
||||||
|
update_at: 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
fakeNotifyProps = (): UserNotifyProps => {
|
||||||
|
return {
|
||||||
|
channel: 'false',
|
||||||
|
comments: 'root',
|
||||||
|
desktop: 'default',
|
||||||
|
desktop_sound: 'false',
|
||||||
|
email: 'false',
|
||||||
|
first_name: 'false',
|
||||||
|
highlight_keys: '',
|
||||||
|
mention_keys: '',
|
||||||
|
push: 'default',
|
||||||
|
push_status: 'away',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -665,6 +706,7 @@ class TestHelper {
|
||||||
};
|
};
|
||||||
|
|
||||||
wait = (time: number) => new Promise((resolve) => setTimeout(resolve, time));
|
wait = (time: number) => new Promise((resolve) => setTimeout(resolve, time));
|
||||||
|
tick = () => new Promise((r) => setImmediate(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new TestHelper();
|
export default new TestHelper();
|
||||||
|
|
|
||||||
1
types/api/config.d.ts
vendored
1
types/api/config.d.ts
vendored
|
|
@ -46,6 +46,7 @@ interface ClientConfig {
|
||||||
EnableBanner: string;
|
EnableBanner: string;
|
||||||
EnableBotAccountCreation: string;
|
EnableBotAccountCreation: string;
|
||||||
EnableChannelViewedMessages: string;
|
EnableChannelViewedMessages: string;
|
||||||
|
EnableClientMetrics?: string;
|
||||||
EnableCluster: string;
|
EnableCluster: string;
|
||||||
EnableCommands: string;
|
EnableCommands: string;
|
||||||
EnableCompliance: string;
|
EnableCompliance: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue