diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33892f949..1f0b93503 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,10 @@ --- name: ci on: + push: + branches: + - main + - 'release*' pull_request: env: diff --git a/.solidarity b/.solidarity index 01799368b..86245c2df 100644 --- a/.solidarity +++ b/.solidarity @@ -39,7 +39,7 @@ { "rule": "cli", "binary": "pod", - "semver": "1.12.1", + "semver": "1.14.3", "platform": "darwin" } ], diff --git a/android/app/build.gradle b/android/app/build.gradle index 53046dbc0..18b1ed588 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -13,8 +13,8 @@ apply plugin: 'kotlin-android' // root = file("../") // The folder where the react-native NPM package is. Default is ../node_modules/react-native // reactNativeDir = file("../node_modules/react-native") - // The folder where the react-native Codegen package is. Default is ../node_modules/react-native-codegen - // codegenDir = file("../node_modules/react-native-codegen") + // The folder where the react-native Codegen package is. Default is ../node_modules/@react-native/codegen + // codegenDir = file("../node_modules/@react-native/codegen") // The cli.js file which is the React Native CLI entrypoint. Default is ../node_modules/react-native/cli.js // cliFile = file("../node_modules/react-native/cli.js") /* Variants */ @@ -98,6 +98,7 @@ def reactNativeArchitectures() { android { ndkVersion rootProject.ext.ndkVersion + buildToolsVersion rootProject.ext.buildToolsVersion compileSdkVersion rootProject.ext.compileSdkVersion namespace "com.mattermost.rnbeta" @@ -110,8 +111,8 @@ android { applicationId "com.mattermost.rnbeta" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 493 - versionName "2.10.0" + versionCode 494 + versionName "2.11.0" testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } @@ -190,8 +191,6 @@ dependencies { // The version of react-native is set by the React Native Gradle Plugin implementation("com.facebook.react:react-android") - implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") - debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") { exclude group:'com.squareup.okhttp3', module:'okhttp' @@ -216,7 +215,6 @@ dependencies { androidTestImplementation('com.wix:detox:+') implementation project(':reactnativenotifications') - implementation project(':watermelondb') implementation project(':watermelondb-jsi') } @@ -224,25 +222,22 @@ configurations.all { resolutionStrategy { eachDependency { DependencyResolveDetails details -> if (details.requested.name == 'play-services-base') { - details.useTarget group: details.requested.group, name: details.requested.name, version: '18.1.0' + details.useTarget group: details.requested.group, name: details.requested.name, version: '18.2.0' } if (details.requested.name == 'play-services-tasks') { details.useTarget group: details.requested.group, name: details.requested.name, version: '18.0.2' } - if (details.requested.name == 'play-services-stats') { - details.useTarget group: details.requested.group, name: details.requested.name, version: '17.0.3' - } if (details.requested.name == 'play-services-basement') { - details.useTarget group: details.requested.group, name: details.requested.name, version: '18.1.0' + details.useTarget group: details.requested.group, name: details.requested.name, version: '18.2.0' } if (details.requested.name == 'okhttp') { - details.useTarget group: details.requested.group, name: details.requested.name, version: '4.10.0' + details.useTarget group: details.requested.group, name: details.requested.name, version: '4.12.0' } if (details.requested.name == 'okhttp-tls') { - details.useTarget group: details.requested.group, name: details.requested.name, version: '4.10.0' + details.useTarget group: details.requested.group, name: details.requested.name, version: '4.12.0' } if (details.requested.name == 'okhttp-urlconnection') { - details.useTarget group: details.requested.group, name: details.requested.name, version: '4.10.0' + details.useTarget group: details.requested.group, name: details.requested.name, version: '4.12.0' } } } diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index e0f941cdc..6a6321f1a 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -7,10 +7,5 @@ - - + tools:ignore="GoogleAppIndexingWarning"/> diff --git a/android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java b/android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java index 0cb97ffc3..392f5f1b7 100644 --- a/android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java +++ b/android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java @@ -29,7 +29,7 @@ import androidx.core.app.RemoteInput; import androidx.core.graphics.drawable.IconCompat; import com.mattermost.rnbeta.*; -import com.nozbe.watermelondb.Database; +import com.nozbe.watermelondb.WMDatabase; import java.io.IOException; import java.util.Date; @@ -81,7 +81,7 @@ public class CustomPushNotificationHelper { .setKey(senderId) .setName(senderName); - if (serverUrl != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) { + if (serverUrl != null && type != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) { try { Bitmap avatar = userAvatar(context, serverUrl, senderId, urlOverride); if (avatar != null) { @@ -133,7 +133,7 @@ public class CustomPushNotificationHelper { private static void addNotificationReplyAction(Context context, NotificationCompat.Builder notification, Bundle bundle, int notificationId) { String postId = bundle.getString("post_id"); String serverUrl = bundle.getString("server_url"); - boolean canReply = bundle.containsKey("category") && bundle.getString("category").equals(CATEGORY_CAN_REPLY); + boolean canReply = bundle.containsKey("category") && Objects.equals(bundle.getString("category"), CATEGORY_CAN_REPLY); if (android.text.TextUtils.isEmpty(postId) || serverUrl == null || !canReply) { return; @@ -183,7 +183,7 @@ public class CustomPushNotificationHelper { String rootId = bundle.getString("root_id"); int notificationId = postId != null ? postId.hashCode() : MESSAGE_NOTIFICATION_ID; - boolean is_crt_enabled = bundle.containsKey("is_crt_enabled") && bundle.getString("is_crt_enabled").equals("true"); + boolean is_crt_enabled = bundle.containsKey("is_crt_enabled") && Objects.equals(bundle.getString("is_crt_enabled"), "true"); String groupId = is_crt_enabled && !android.text.TextUtils.isEmpty(rootId) ? rootId : channelId; addNotificationExtras(notification, bundle); @@ -275,7 +275,7 @@ public class CustomPushNotificationHelper { .setKey(senderId) .setName("Me"); - if (serverUrl != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) { + if (serverUrl != null && type != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) { try { Bitmap avatar = userAvatar(context, serverUrl, "me", urlOverride); if (avatar != null) { @@ -418,7 +418,7 @@ public class CustomPushNotificationHelper { } else { DatabaseHelper dbHelper = DatabaseHelper.Companion.getInstance(); if (dbHelper != null) { - Database db = getDatabaseForServer(dbHelper, context, serverUrl); + WMDatabase db = getDatabaseForServer(dbHelper, context, serverUrl); if (db != null) { lastUpdateAt = getLastPictureUpdate(db, userId); if (lastUpdateAt == null) { diff --git a/android/app/src/main/java/com/mattermost/helpers/DatabaseHelper.kt b/android/app/src/main/java/com/mattermost/helpers/DatabaseHelper.kt index fcbb53aaf..f0d8694a6 100644 --- a/android/app/src/main/java/com/mattermost/helpers/DatabaseHelper.kt +++ b/android/app/src/main/java/com/mattermost/helpers/DatabaseHelper.kt @@ -1,17 +1,21 @@ package com.mattermost.helpers import android.content.Context +import android.database.Cursor import android.net.Uri +import com.facebook.react.bridge.WritableMap -import com.nozbe.watermelondb.Database +import com.nozbe.watermelondb.WMDatabase import java.lang.Exception import org.json.JSONArray import org.json.JSONObject +typealias QueryArgs = Array + class DatabaseHelper { - var defaultDatabase: Database? = null + var defaultDatabase: WMDatabase? = null val onlyServerUrl: String? get() { @@ -39,7 +43,7 @@ class DatabaseHelper { private fun setDefaultDatabase(context: Context) { val databaseName = "app.db" val databasePath = Uri.fromFile(context.filesDir).toString() + "/" + databaseName - defaultDatabase = Database.getInstance(databasePath, context) + defaultDatabase = WMDatabase.getInstance(databasePath, context) } internal fun JSONObject.toMap(): Map = keys().asSequence().associateWith { it -> @@ -73,3 +77,15 @@ class DatabaseHelper { private set } } + +fun WritableMap.mapCursor(cursor: Cursor) { + for (i in 0 until cursor.columnCount) { + when (cursor.getType(i)) { + Cursor.FIELD_TYPE_NULL -> putNull(cursor.getColumnName(i)) + Cursor.FIELD_TYPE_INTEGER -> putDouble(cursor.getColumnName(i), cursor.getDouble(i)) + Cursor.FIELD_TYPE_FLOAT -> putDouble(cursor.getColumnName(i), cursor.getDouble(i)) + Cursor.FIELD_TYPE_STRING -> putString(cursor.getColumnName(i), cursor.getString(i)) + else -> putString(cursor.getColumnName(i), "") + } + } +} diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/Category.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/Category.kt index 0dcc83d72..905be7a89 100644 --- a/android/app/src/main/java/com/mattermost/helpers/database_extension/Category.kt +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/Category.kt @@ -2,9 +2,9 @@ package com.mattermost.helpers.database_extension import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap -import com.nozbe.watermelondb.Database +import com.nozbe.watermelondb.WMDatabase -fun insertCategory(db: Database, category: ReadableMap) { +fun insertCategory(db: WMDatabase, category: ReadableMap) { try { val id = category.getString("id") ?: return val collapsed = false @@ -31,7 +31,7 @@ fun insertCategory(db: Database, category: ReadableMap) { } } -fun insertCategoryChannels(db: Database, categoryId: String, teamId: String, channelIds: ReadableArray) { +fun insertCategoryChannels(db: WMDatabase, categoryId: String, teamId: String, channelIds: ReadableArray) { try { for (i in 0 until channelIds.size()) { val channelId = channelIds.getString(i) @@ -50,7 +50,7 @@ fun insertCategoryChannels(db: Database, categoryId: String, teamId: String, cha } } -fun insertCategoriesWithChannels(db: Database, orderCategories: ReadableMap) { +fun insertCategoriesWithChannels(db: WMDatabase, orderCategories: ReadableMap) { val categories = orderCategories.getArray("categories") ?: return for (i in 0 until categories.size()) { val category = categories.getMap(i) @@ -64,7 +64,7 @@ fun insertCategoriesWithChannels(db: Database, orderCategories: ReadableMap) { } } -fun insertChannelToDefaultCategory(db: Database, categoryChannels: ReadableArray) { +fun insertChannelToDefaultCategory(db: WMDatabase, categoryChannels: ReadableArray) { try { for (i in 0 until categoryChannels.size()) { val cc = categoryChannels.getMap(i) diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/Channel.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/Channel.kt index 635b5b4f0..1d0efb40c 100644 --- a/android/app/src/main/java/com/mattermost/helpers/database_extension/Channel.kt +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/Channel.kt @@ -3,11 +3,11 @@ package com.mattermost.helpers.database_extension import com.facebook.react.bridge.ReadableMap import com.mattermost.helpers.DatabaseHelper import com.mattermost.helpers.ReadableMapUtils -import com.nozbe.watermelondb.Database +import com.nozbe.watermelondb.WMDatabase import org.json.JSONException import org.json.JSONObject -fun findChannel(db: Database?, channelId: String): Boolean { +fun findChannel(db: WMDatabase?, channelId: String): Boolean { if (db != null) { val team = find(db, "Channel", channelId) return team != null @@ -15,7 +15,7 @@ fun findChannel(db: Database?, channelId: String): Boolean { return false } -fun findMyChannel(db: Database?, channelId: String): Boolean { +fun findMyChannel(db: WMDatabase?, channelId: String): Boolean { if (db != null) { val team = find(db, "MyChannel", channelId) return team != null @@ -23,7 +23,7 @@ fun findMyChannel(db: Database?, channelId: String): Boolean { return false } -internal fun handleChannel(db: Database, channel: ReadableMap) { +internal fun handleChannel(db: WMDatabase, channel: ReadableMap) { try { val exists = channel.getString("id")?.let { findChannel(db, it) } ?: false if (!exists) { @@ -37,7 +37,7 @@ internal fun handleChannel(db: Database, channel: ReadableMap) { } } -internal fun DatabaseHelper.handleMyChannel(db: Database, myChannel: ReadableMap, postsData: ReadableMap?, receivingThreads: Boolean) { +internal fun DatabaseHelper.handleMyChannel(db: WMDatabase, myChannel: ReadableMap, postsData: ReadableMap?, receivingThreads: Boolean) { try { val json = ReadableMapUtils.toJSONObject(myChannel) val exists = myChannel.getString("id")?.let { findMyChannel(db, it) } ?: false @@ -71,7 +71,7 @@ internal fun DatabaseHelper.handleMyChannel(db: Database, myChannel: ReadableMap } } -fun insertChannel(db: Database, channel: JSONObject): Boolean { +fun insertChannel(db: WMDatabase, channel: JSONObject): Boolean { val id = try { channel.getString("id") } catch (e: JSONException) { return false } val createAt = try { channel.getDouble("create_at") } catch (e: JSONException) { 0 } val deleteAt = try { channel.getDouble("delete_at") } catch (e: JSONException) { 0 } @@ -104,7 +104,7 @@ fun insertChannel(db: Database, channel: JSONObject): Boolean { } } -fun insertChannelInfo(db: Database, channel: JSONObject) { +fun insertChannelInfo(db: WMDatabase, channel: JSONObject) { val id = try { channel.getString("id") } catch (e: JSONException) { return } val header = try { channel.getString("header") } catch (e: JSONException) { "" } val purpose = try { channel.getString("purpose") } catch (e: JSONException) { "" } @@ -123,7 +123,7 @@ fun insertChannelInfo(db: Database, channel: JSONObject) { } } -fun insertMyChannel(db: Database, myChanel: JSONObject): Boolean { +fun insertMyChannel(db: WMDatabase, myChanel: JSONObject): Boolean { return try { val id = try { myChanel.getString("id") } catch (e: JSONException) { return false } val roles = try { myChanel.getString("roles") } catch (e: JSONException) { "" } @@ -156,7 +156,7 @@ fun insertMyChannel(db: Database, myChanel: JSONObject): Boolean { } } -fun insertMyChannelSettings(db: Database, myChanel: JSONObject) { +fun insertMyChannelSettings(db: WMDatabase, myChanel: JSONObject) { try { val id = try { myChanel.getString("id") } catch (e: JSONException) { return } val notifyProps = try { myChanel.getString("notify_props") } catch (e: JSONException) { return } @@ -173,7 +173,7 @@ fun insertMyChannelSettings(db: Database, myChanel: JSONObject) { } } -fun insertChannelMember(db: Database, myChanel: JSONObject) { +fun insertChannelMember(db: WMDatabase, myChanel: JSONObject) { try { val userId = queryCurrentUserId(db) ?: return val channelId = try { myChanel.getString("id") } catch (e: JSONException) { return } @@ -193,7 +193,7 @@ fun insertChannelMember(db: Database, myChanel: JSONObject) { } } -fun updateMyChannel(db: Database, myChanel: JSONObject) { +fun updateMyChannel(db: WMDatabase, myChanel: JSONObject) { try { val id = try { myChanel.getString("id") } catch (e: JSONException) { return } val msgCount = try { myChanel.getInt("message_count") } catch (e: JSONException) { 0 } diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/CustomEmoji.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/CustomEmoji.kt index c9d9c0154..ba80c6f03 100644 --- a/android/app/src/main/java/com/mattermost/helpers/database_extension/CustomEmoji.kt +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/CustomEmoji.kt @@ -1,9 +1,9 @@ package com.mattermost.helpers.database_extension -import com.nozbe.watermelondb.Database +import com.nozbe.watermelondb.WMDatabase import org.json.JSONArray -internal fun insertCustomEmojis(db: Database, customEmojis: JSONArray) { +internal fun insertCustomEmojis(db: WMDatabase, customEmojis: JSONArray) { for (i in 0 until customEmojis.length()) { try { val emoji = customEmojis.getJSONObject(i) diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/File.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/File.kt index 460ec0aa2..55f55f80d 100644 --- a/android/app/src/main/java/com/mattermost/helpers/database_extension/File.kt +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/File.kt @@ -1,10 +1,10 @@ package com.mattermost.helpers.database_extension -import com.nozbe.watermelondb.Database +import com.nozbe.watermelondb.WMDatabase import org.json.JSONArray import org.json.JSONException -internal fun insertFiles(db: Database, files: JSONArray) { +internal fun insertFiles(db: WMDatabase, files: JSONArray) { try { for (i in 0 until files.length()) { val file = files.getJSONObject(i) diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/General.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/General.kt index 5c8e46152..64c31b8cf 100644 --- a/android/app/src/main/java/com/mattermost/helpers/database_extension/General.kt +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/General.kt @@ -5,13 +5,13 @@ import android.text.TextUtils import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.ReadableMap import com.mattermost.helpers.DatabaseHelper -import com.nozbe.watermelondb.Database -import com.nozbe.watermelondb.QueryArgs -import com.nozbe.watermelondb.mapCursor +import com.mattermost.helpers.QueryArgs +import com.mattermost.helpers.mapCursor +import com.nozbe.watermelondb.WMDatabase import java.util.* import kotlin.Exception -internal fun DatabaseHelper.saveToDatabase(db: Database, data: ReadableMap, teamId: String?, channelId: String?, receivingThreads: Boolean) { +internal fun DatabaseHelper.saveToDatabase(db: WMDatabase, data: ReadableMap, teamId: String?, channelId: String?, receivingThreads: Boolean) { db.transaction { val posts = data.getMap("posts") data.getMap("team")?.let { insertTeam(db, it) } @@ -50,14 +50,14 @@ fun DatabaseHelper.getServerUrlForIdentifier(identifier: String): String? { return null } -fun DatabaseHelper.getDatabaseForServer(context: Context?, serverUrl: String): Database? { +fun DatabaseHelper.getDatabaseForServer(context: Context?, serverUrl: String): WMDatabase? { try { val query = "SELECT db_path FROM Servers WHERE url=?" defaultDatabase!!.rawQuery(query, arrayOf(serverUrl)).use { cursor -> if (cursor.count == 1) { cursor.moveToFirst() val databasePath = cursor.getString(0) - return Database.getInstance(databasePath, context!!) + return WMDatabase.getInstance(databasePath, context!!) } } } catch (e: Exception) { @@ -67,7 +67,7 @@ fun DatabaseHelper.getDatabaseForServer(context: Context?, serverUrl: String): D return null } -fun find(db: Database, tableName: String, id: String?): ReadableMap? { +fun find(db: WMDatabase, tableName: String, id: String?): ReadableMap? { try { db.rawQuery( "SELECT * FROM $tableName WHERE id == ? LIMIT 1", @@ -87,7 +87,7 @@ fun find(db: Database, tableName: String, id: String?): ReadableMap? { } } -fun findByColumns(db: Database, tableName: String, columnNames: Array, values: QueryArgs): ReadableMap? { +fun findByColumns(db: WMDatabase, tableName: String, columnNames: Array, values: QueryArgs): ReadableMap? { try { val whereString = columnNames.joinToString(" AND ") { "$it = ?" } db.rawQuery( @@ -108,7 +108,7 @@ fun findByColumns(db: Database, tableName: String, columnNames: Array, v } } -fun queryIds(db: Database, tableName: String, ids: Array): List { +fun queryIds(db: WMDatabase, tableName: String, ids: Array): List { val list: MutableList = ArrayList() val args = TextUtils.join(",", Arrays.stream(ids).map { "?" }.toArray()) try { @@ -129,7 +129,7 @@ fun queryIds(db: Database, tableName: String, ids: Array): List return list } -fun queryByColumn(db: Database, tableName: String, columnName: String, values: Array): List { +fun queryByColumn(db: WMDatabase, tableName: String, columnName: String, values: Array): List { val list: MutableList = ArrayList() val args = TextUtils.join(",", Arrays.stream(values).map { "?" }.toArray()) try { @@ -149,7 +149,7 @@ fun queryByColumn(db: Database, tableName: String, columnName: String, values: A return list } -fun countByColumn(db: Database, tableName: String, columnName: String, value: Any?): Int { +fun countByColumn(db: WMDatabase, tableName: String, columnName: String, value: Any?): Int { try { db.rawQuery( "SELECT COUNT(*) FROM $tableName WHERE $columnName == ? LIMIT 1", diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/Post.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/Post.kt index 61e463247..e4a911c39 100644 --- a/android/app/src/main/java/com/mattermost/helpers/database_extension/Post.kt +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/Post.kt @@ -3,13 +3,13 @@ package com.mattermost.helpers.database_extension import com.facebook.react.bridge.ReadableMap import com.mattermost.helpers.DatabaseHelper import com.mattermost.helpers.ReadableMapUtils -import com.nozbe.watermelondb.Database +import com.nozbe.watermelondb.WMDatabase import org.json.JSONArray import org.json.JSONException import org.json.JSONObject import kotlin.Exception -internal fun queryLastPostCreateAt(db: Database?, channelId: String): Double? { +internal fun queryLastPostCreateAt(db: WMDatabase?, channelId: String): Double? { try { if (db != null) { val postsInChannelQuery = "SELECT earliest, latest FROM PostsInChannel WHERE channel_id=? ORDER BY latest DESC LIMIT 1" @@ -35,7 +35,7 @@ internal fun queryLastPostCreateAt(db: Database?, channelId: String): Double? { return null } -fun queryPostSinceForChannel(db: Database?, channelId: String): Double? { +fun queryPostSinceForChannel(db: WMDatabase?, channelId: String): Double? { try { if (db != null) { val postsInChannelQuery = "SELECT last_fetched_at FROM MyChannel WHERE id=? LIMIT 1" @@ -57,7 +57,7 @@ fun queryPostSinceForChannel(db: Database?, channelId: String): Double? { return null } -fun queryLastPostInThread(db: Database?, rootId: String): Double? { +fun queryLastPostInThread(db: WMDatabase?, rootId: String): Double? { try { if (db != null) { val query = "SELECT create_at FROM Post WHERE root_id=? AND delete_at=0 ORDER BY create_at DESC LIMIT 1" @@ -75,7 +75,7 @@ fun queryLastPostInThread(db: Database?, rootId: String): Double? { return null } -internal fun insertPost(db: Database, post: JSONObject) { +internal fun insertPost(db: WMDatabase, post: JSONObject) { try { val id = try { post.getString("id") } catch (e: JSONException) { return } val channelId = try { post.getString("channel_id") } catch (e: JSONException) { return } @@ -129,7 +129,7 @@ internal fun insertPost(db: Database, post: JSONObject) { } } -internal fun updatePost(db: Database, post: JSONObject) { +internal fun updatePost(db: WMDatabase, post: JSONObject) { try { val id = try { post.getString("id") } catch (e: JSONException) { return } val channelId = try { post.getString("channel_id") } catch (e: JSONException) { return } @@ -182,7 +182,7 @@ internal fun updatePost(db: Database, post: JSONObject) { } } -fun DatabaseHelper.handlePosts(db: Database, postsData: ReadableMap?, channelId: String, receivingThreads: Boolean) { +fun DatabaseHelper.handlePosts(db: WMDatabase, postsData: ReadableMap?, channelId: String, receivingThreads: Boolean) { // Posts, PostInChannel, PostInThread, Reactions, Files, CustomEmojis, Users try { if (postsData != null) { diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/PostsInChannel.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/PostsInChannel.kt index 06a9abfa7..d992ba131 100644 --- a/android/app/src/main/java/com/mattermost/helpers/database_extension/PostsInChannel.kt +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/PostsInChannel.kt @@ -4,8 +4,8 @@ import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap import com.mattermost.helpers.RandomId -import com.nozbe.watermelondb.Database -import com.nozbe.watermelondb.mapCursor +import com.mattermost.helpers.mapCursor +import com.nozbe.watermelondb.WMDatabase internal fun findPostInChannel(chunks: ReadableArray, earliest: Double, latest: Double): ReadableMap? { for (i in 0 until chunks.size()) { @@ -18,7 +18,7 @@ internal fun findPostInChannel(chunks: ReadableArray, earliest: Double, latest: return null } -internal fun insertPostInChannel(db: Database, channelId: String, earliest: Double, latest: Double): ReadableMap? { +internal fun insertPostInChannel(db: WMDatabase, channelId: String, earliest: Double, latest: Double): ReadableMap? { return try { val id = RandomId.generate() db.execute( @@ -41,7 +41,7 @@ internal fun insertPostInChannel(db: Database, channelId: String, earliest: Doub } } -internal fun mergePostsInChannel(db: Database, existingChunks: ReadableArray, newChunk: ReadableMap) { +internal fun mergePostsInChannel(db: WMDatabase, existingChunks: ReadableArray, newChunk: ReadableMap) { for (i in 0 until existingChunks.size()) { try { val chunk = existingChunks.getMap(i) @@ -56,7 +56,7 @@ internal fun mergePostsInChannel(db: Database, existingChunks: ReadableArray, ne } } -internal fun handlePostsInChannel(db: Database, channelId: String, earliest: Double, latest: Double) { +internal fun handlePostsInChannel(db: WMDatabase, channelId: String, earliest: Double, latest: Double) { try { db.rawQuery( "SELECT id, channel_id, earliest, latest FROM PostsInChannel WHERE channel_id = ?", diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/Preference.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/Preference.kt index d41dc8f49..471be4162 100644 --- a/android/app/src/main/java/com/mattermost/helpers/database_extension/Preference.kt +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/Preference.kt @@ -1,10 +1,10 @@ package com.mattermost.helpers.database_extension import com.facebook.react.bridge.Arguments -import com.nozbe.watermelondb.Database -import com.nozbe.watermelondb.mapCursor +import com.mattermost.helpers.mapCursor +import com.nozbe.watermelondb.WMDatabase -fun getTeammateDisplayNameSetting(db: Database): String { +fun getTeammateDisplayNameSetting(db: WMDatabase): String { val configSetting = queryConfigDisplayNameSetting(db) if (configSetting != null) { return configSetting diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/Reaction.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/Reaction.kt index 2cb0c4f8f..bf4dc00e2 100644 --- a/android/app/src/main/java/com/mattermost/helpers/database_extension/Reaction.kt +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/Reaction.kt @@ -1,10 +1,10 @@ package com.mattermost.helpers.database_extension import com.mattermost.helpers.RandomId -import com.nozbe.watermelondb.Database +import com.nozbe.watermelondb.WMDatabase import org.json.JSONArray -internal fun insertReactions(db: Database, reactions: JSONArray) { +internal fun insertReactions(db: WMDatabase, reactions: JSONArray) { for (i in 0 until reactions.length()) { try { val reaction = reactions.getJSONObject(i) diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/System.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/System.kt index 8d18379af..efbf48449 100644 --- a/android/app/src/main/java/com/mattermost/helpers/database_extension/System.kt +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/System.kt @@ -1,19 +1,19 @@ package com.mattermost.helpers.database_extension -import com.nozbe.watermelondb.Database +import com.nozbe.watermelondb.WMDatabase import org.json.JSONObject -fun queryCurrentUserId(db: Database): String? { +fun queryCurrentUserId(db: WMDatabase): String? { val result = find(db, "System", "currentUserId") return result?.getString("value")?.removeSurrounding("\"") } -fun queryCurrentTeamId(db: Database): String? { +fun queryCurrentTeamId(db: WMDatabase): String? { val result = find(db, "System", "currentTeamId") return result?.getString("value")?.removeSurrounding("\"") } -fun queryConfigDisplayNameSetting(db: Database): String? { +fun queryConfigDisplayNameSetting(db: WMDatabase): String? { val license = find(db, "System", "license") val lockDisplayName = find(db, "Config", "LockTeammateNameDisplay") val displayName = find(db, "Config", "TeammateNameDisplay") diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/Team.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/Team.kt index a3fbf6d59..9591fad3b 100644 --- a/android/app/src/main/java/com/mattermost/helpers/database_extension/Team.kt +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/Team.kt @@ -3,10 +3,10 @@ package com.mattermost.helpers.database_extension import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.NoSuchKeyException import com.facebook.react.bridge.ReadableMap -import com.nozbe.watermelondb.Database -import com.nozbe.watermelondb.mapCursor +import com.mattermost.helpers.mapCursor +import com.nozbe.watermelondb.WMDatabase -fun findTeam(db: Database?, teamId: String): Boolean { +fun findTeam(db: WMDatabase?, teamId: String): Boolean { if (db != null) { val team = find(db, "Team", teamId) return team != null @@ -14,7 +14,7 @@ fun findTeam(db: Database?, teamId: String): Boolean { return false } -fun findMyTeam(db: Database?, teamId: String): Boolean { +fun findMyTeam(db: WMDatabase?, teamId: String): Boolean { if (db != null) { val team = find(db, "MyTeam", teamId) return team != null @@ -22,7 +22,7 @@ fun findMyTeam(db: Database?, teamId: String): Boolean { return false } -fun queryMyTeams(db: Database?): ArrayList? { +fun queryMyTeams(db: WMDatabase?): ArrayList? { db?.rawQuery("SELECT * FROM MyTeam")?.use { cursor -> val results = ArrayList() if (cursor.count > 0) { @@ -38,7 +38,7 @@ fun queryMyTeams(db: Database?): ArrayList? { return null } -fun insertTeam(db: Database, team: ReadableMap): Boolean { +fun insertTeam(db: WMDatabase, team: ReadableMap): Boolean { val id = try { team.getString("id") } catch (e: Exception) { return false } val deleteAt = try {team.getDouble("delete_at") } catch (e: Exception) { 0 } if (deleteAt.toInt() > 0) { @@ -78,7 +78,7 @@ fun insertTeam(db: Database, team: ReadableMap): Boolean { } } -fun insertMyTeam(db: Database, myTeam: ReadableMap): Boolean { +fun insertMyTeam(db: WMDatabase, myTeam: ReadableMap): Boolean { val currentUserId = queryCurrentUserId(db) ?: return false val id = try { myTeam.getString("id") } catch (e: NoSuchKeyException) { return false } val roles = try { myTeam.getString("roles") } catch (e: NoSuchKeyException) { "" } diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/Thread.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/Thread.kt index 1ed6f5411..efdbebb77 100644 --- a/android/app/src/main/java/com/mattermost/helpers/database_extension/Thread.kt +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/Thread.kt @@ -5,11 +5,11 @@ import com.facebook.react.bridge.NoSuchKeyException import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap import com.mattermost.helpers.RandomId -import com.nozbe.watermelondb.Database -import com.nozbe.watermelondb.mapCursor +import com.mattermost.helpers.mapCursor +import com.nozbe.watermelondb.WMDatabase import org.json.JSONObject -internal fun insertThread(db: Database, thread: ReadableMap) { +internal fun insertThread(db: WMDatabase, thread: ReadableMap) { // These fields are not present when we extract threads from posts try { val id = try { thread.getString("id") } catch (e: NoSuchKeyException) { return } @@ -36,7 +36,7 @@ internal fun insertThread(db: Database, thread: ReadableMap) { } } -internal fun updateThread(db: Database, thread: ReadableMap, existingRecord: ReadableMap) { +internal fun updateThread(db: WMDatabase, thread: ReadableMap, existingRecord: ReadableMap) { try { // These fields are not present when we extract threads from posts val id = try { thread.getString("id") } catch (e: NoSuchKeyException) { return } @@ -63,7 +63,7 @@ internal fun updateThread(db: Database, thread: ReadableMap, existingRecord: Rea } } -internal fun insertThreadParticipants(db: Database, threadId: String, participants: ReadableArray) { +internal fun insertThreadParticipants(db: WMDatabase, threadId: String, participants: ReadableArray) { for (i in 0 until participants.size()) { try { val participant = participants.getMap(i) @@ -82,7 +82,7 @@ internal fun insertThreadParticipants(db: Database, threadId: String, participan } } -fun insertTeamThreadsSync(db: Database, teamId: String, earliest: Double, latest: Double) { +fun insertTeamThreadsSync(db: WMDatabase, teamId: String, earliest: Double, latest: Double) { try { val query = """ INSERT INTO TeamThreadsSync (id, _changed, _status, earliest, latest) @@ -94,7 +94,7 @@ fun insertTeamThreadsSync(db: Database, teamId: String, earliest: Double, latest } } -fun updateTeamThreadsSync(db: Database, teamId: String, earliest: Double, latest: Double, existingRecord: ReadableMap) { +fun updateTeamThreadsSync(db: WMDatabase, teamId: String, earliest: Double, latest: Double, existingRecord: ReadableMap) { try { val storeEarliest = minOf(earliest, existingRecord.getDouble("earliest")) val storeLatest = maxOf(latest, existingRecord.getDouble("latest")) @@ -105,7 +105,7 @@ fun updateTeamThreadsSync(db: Database, teamId: String, earliest: Double, latest } } -fun syncParticipants(db: Database, thread: ReadableMap) { +fun syncParticipants(db: WMDatabase, thread: ReadableMap) { try { val threadId = thread.getString("id") val participants = thread.getArray("participants") @@ -121,7 +121,7 @@ fun syncParticipants(db: Database, thread: ReadableMap) { } } -internal fun handlePostsInThread(db: Database, postsInThread: Map>) { +internal fun handlePostsInThread(db: WMDatabase, postsInThread: Map>) { postsInThread.forEach { (key, list) -> try { val sorted = list.sortedBy { it.getDouble("create_at") } @@ -161,7 +161,7 @@ internal fun handlePostsInThread(db: Database, postsInThread: Map, teamId: String?) { +fun handleThreads(db: WMDatabase, threads: ArrayList, teamId: String?) { val teamIds = ArrayList() if (teamId.isNullOrEmpty()) { val myTeams = queryMyTeams(db) @@ -186,7 +186,7 @@ fun handleThreads(db: Database, threads: ArrayList, teamId: String? handleTeamThreadsSync(db, threads, teamIds) } -fun handleThread(db: Database, thread: ReadableMap, teamIds: ArrayList) { +fun handleThread(db: WMDatabase, thread: ReadableMap, teamIds: ArrayList) { // Insert/Update the thread val threadId = thread.getString("id") val isFollowing = thread.getBoolean("is_following") @@ -207,7 +207,7 @@ fun handleThread(db: Database, thread: ReadableMap, teamIds: ArrayList) } } -fun handleThreadInTeam(db: Database, thread: ReadableMap, teamId: String) { +fun handleThreadInTeam(db: WMDatabase, thread: ReadableMap, teamId: String) { val threadId = thread.getString("id") ?: return val existingRecord = findByColumns( db, @@ -229,7 +229,7 @@ fun handleThreadInTeam(db: Database, thread: ReadableMap, teamId: String) { } } -fun handleTeamThreadsSync(db: Database, threadList: ArrayList, teamIds: ArrayList) { +fun handleTeamThreadsSync(db: WMDatabase, threadList: ArrayList, teamIds: ArrayList) { val sortedList = threadList.filter{ it.getBoolean("is_following") } .sortedBy { it.getDouble("last_reply_at") } .map { it.getDouble("last_reply_at") } diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/User.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/User.kt index a379bfc6a..ff452ea67 100644 --- a/android/app/src/main/java/com/mattermost/helpers/database_extension/User.kt +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/User.kt @@ -4,9 +4,9 @@ import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.NoSuchKeyException import com.facebook.react.bridge.ReadableArray import com.mattermost.helpers.ReadableMapUtils -import com.nozbe.watermelondb.Database +import com.nozbe.watermelondb.WMDatabase -fun getLastPictureUpdate(db: Database?, userId: String): Double? { +fun getLastPictureUpdate(db: WMDatabase?, userId: String): Double? { try { if (db != null) { var id = userId @@ -28,7 +28,7 @@ fun getLastPictureUpdate(db: Database?, userId: String): Double? { return null } -fun getCurrentUserLocale(db: Database): String { +fun getCurrentUserLocale(db: WMDatabase): String { try { val currentUserId = queryCurrentUserId(db) ?: return "en" val userQuery = "SELECT locale FROM User WHERE id=?" @@ -45,7 +45,7 @@ fun getCurrentUserLocale(db: Database): String { return "en" } -fun handleUsers(db: Database, users: ReadableArray) { +fun handleUsers(db: WMDatabase, users: ReadableArray) { for (i in 0 until users.size()) { val user = users.getMap(i) val roles = user.getString("roles") ?: "" diff --git a/android/app/src/main/java/com/mattermost/helpers/push_notification/Category.kt b/android/app/src/main/java/com/mattermost/helpers/push_notification/Category.kt index c0ba078f8..c2743bf3a 100644 --- a/android/app/src/main/java/com/mattermost/helpers/push_notification/Category.kt +++ b/android/app/src/main/java/com/mattermost/helpers/push_notification/Category.kt @@ -7,9 +7,9 @@ import com.mattermost.helpers.PushNotificationDataRunnable import com.mattermost.helpers.database_extension.findByColumns import com.mattermost.helpers.database_extension.queryCurrentUserId import com.mattermost.helpers.database_extension.queryMyTeams -import com.nozbe.watermelondb.Database +import com.nozbe.watermelondb.WMDatabase -suspend fun PushNotificationDataRunnable.Companion.fetchMyTeamCategories(db: Database, serverUrl: String, teamId: String): ReadableMap? { +suspend fun PushNotificationDataRunnable.Companion.fetchMyTeamCategories(db: WMDatabase, serverUrl: String, teamId: String): ReadableMap? { return try { val userId = queryCurrentUserId(db) val categories = fetch(serverUrl, "/api/v4/users/$userId/teams/$teamId/channels/categories") @@ -20,7 +20,7 @@ suspend fun PushNotificationDataRunnable.Companion.fetchMyTeamCategories(db: Dat } } -fun PushNotificationDataRunnable.Companion.addToDefaultCategoryIfNeeded(db: Database, channel: ReadableMap): ReadableArray? { +fun PushNotificationDataRunnable.Companion.addToDefaultCategoryIfNeeded(db: WMDatabase, channel: ReadableMap): ReadableArray? { val channelId = channel.getString("id") ?: return null val channelType = channel.getString("type") val categoryChannels = Arguments.createArray() @@ -44,7 +44,7 @@ fun PushNotificationDataRunnable.Companion.addToDefaultCategoryIfNeeded(db: Data return categoryChannels } -private fun categoryChannelForTeam(db: Database, channelId: String, teamId: String?, type: String): ReadableMap? { +private fun categoryChannelForTeam(db: WMDatabase, channelId: String, teamId: String?, type: String): ReadableMap? { teamId?.let { id -> val category = findByColumns(db, "Category", arrayOf("type", "team_id"), arrayOf(type, id)) val categoryId = category?.getString("id") diff --git a/android/app/src/main/java/com/mattermost/helpers/push_notification/Channel.kt b/android/app/src/main/java/com/mattermost/helpers/push_notification/Channel.kt index 004a3801b..cbee3e4aa 100644 --- a/android/app/src/main/java/com/mattermost/helpers/push_notification/Channel.kt +++ b/android/app/src/main/java/com/mattermost/helpers/push_notification/Channel.kt @@ -8,12 +8,11 @@ import com.mattermost.helpers.database_extension.findChannel import com.mattermost.helpers.database_extension.getCurrentUserLocale import com.mattermost.helpers.database_extension.getTeammateDisplayNameSetting import com.mattermost.helpers.database_extension.queryCurrentUserId -import com.nozbe.watermelondb.Database +import com.nozbe.watermelondb.WMDatabase import java.text.Collator import java.util.Locale -import kotlin.math.max -suspend fun PushNotificationDataRunnable.Companion.fetchMyChannel(db: Database, serverUrl: String, channelId: String, isCRTEnabled: Boolean): Triple { +suspend fun PushNotificationDataRunnable.Companion.fetchMyChannel(db: WMDatabase, serverUrl: String, channelId: String, isCRTEnabled: Boolean): Triple { val channel = fetch(serverUrl, "/api/v4/channels/$channelId") var channelData = channel?.getMap("data") val myChannelData = channelData?.let { fetchMyChannelData(serverUrl, channelId, isCRTEnabled, it) } @@ -109,7 +108,7 @@ private suspend fun PushNotificationDataRunnable.Companion.fetchMyChannelData(se return null } -private suspend fun PushNotificationDataRunnable.Companion.fetchProfileInChannel(db: Database, serverUrl: String, channelId: String): ReadableArray? { +private suspend fun PushNotificationDataRunnable.Companion.fetchProfileInChannel(db: WMDatabase, serverUrl: String, channelId: String): ReadableArray? { return try { val currentUserId = queryCurrentUserId(db) val profilesInChannel = fetch(serverUrl, "/api/v4/users?in_channel=${channelId}&page=0&per_page=8&sort=") diff --git a/android/app/src/main/java/com/mattermost/helpers/push_notification/Post.kt b/android/app/src/main/java/com/mattermost/helpers/push_notification/Post.kt index 6fb35c6ed..8b46577b8 100644 --- a/android/app/src/main/java/com/mattermost/helpers/push_notification/Post.kt +++ b/android/app/src/main/java/com/mattermost/helpers/push_notification/Post.kt @@ -9,10 +9,10 @@ import com.mattermost.helpers.PushNotificationDataRunnable import com.mattermost.helpers.ReadableArrayUtils import com.mattermost.helpers.ReadableMapUtils import com.mattermost.helpers.database_extension.* -import com.nozbe.watermelondb.Database +import com.nozbe.watermelondb.WMDatabase internal suspend fun PushNotificationDataRunnable.Companion.fetchPosts( - db: Database, serverUrl: String, channelId: String, isCRTEnabled: Boolean, + db: WMDatabase, serverUrl: String, channelId: String, isCRTEnabled: Boolean, rootId: String?, loadedProfiles: ReadableArray? ): ReadableMap? { return try { diff --git a/android/app/src/main/java/com/mattermost/helpers/push_notification/Team.kt b/android/app/src/main/java/com/mattermost/helpers/push_notification/Team.kt index 92ba65549..557ddb11b 100644 --- a/android/app/src/main/java/com/mattermost/helpers/push_notification/Team.kt +++ b/android/app/src/main/java/com/mattermost/helpers/push_notification/Team.kt @@ -4,9 +4,9 @@ import com.facebook.react.bridge.ReadableMap import com.mattermost.helpers.PushNotificationDataRunnable import com.mattermost.helpers.database_extension.findMyTeam import com.mattermost.helpers.database_extension.findTeam -import com.nozbe.watermelondb.Database +import com.nozbe.watermelondb.WMDatabase -suspend fun PushNotificationDataRunnable.Companion.fetchTeamIfNeeded(db: Database, serverUrl: String, teamId: String): Pair { +suspend fun PushNotificationDataRunnable.Companion.fetchTeamIfNeeded(db: WMDatabase, serverUrl: String, teamId: String): Pair { return try { var team: ReadableMap? = null var myTeam: ReadableMap? = null diff --git a/android/app/src/main/java/com/mattermost/helpers/push_notification/Thread.kt b/android/app/src/main/java/com/mattermost/helpers/push_notification/Thread.kt index 0171ca650..69af29f08 100644 --- a/android/app/src/main/java/com/mattermost/helpers/push_notification/Thread.kt +++ b/android/app/src/main/java/com/mattermost/helpers/push_notification/Thread.kt @@ -3,9 +3,9 @@ package com.mattermost.helpers.push_notification import com.facebook.react.bridge.ReadableMap import com.mattermost.helpers.PushNotificationDataRunnable import com.mattermost.helpers.database_extension.* -import com.nozbe.watermelondb.Database +import com.nozbe.watermelondb.WMDatabase -internal suspend fun PushNotificationDataRunnable.Companion.fetchThread(db: Database, serverUrl: String, threadId: String, teamId: String?): ReadableMap? { +internal suspend fun PushNotificationDataRunnable.Companion.fetchThread(db: WMDatabase, serverUrl: String, threadId: String, teamId: String?): ReadableMap? { val currentUserId = queryCurrentUserId(db) ?: return null val threadTeamId = (if (teamId.isNullOrEmpty()) queryCurrentTeamId(db) else teamId) ?: return null diff --git a/android/app/src/main/java/com/mattermost/rnbeta/MainActivity.java b/android/app/src/main/java/com/mattermost/rnbeta/MainActivity.java index 0ec58ae64..e5540ea5d 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/MainActivity.java +++ b/android/app/src/main/java/com/mattermost/rnbeta/MainActivity.java @@ -36,10 +36,7 @@ public class MainActivity extends NavigationActivity { this, Objects.requireNonNull(getMainComponentName()), // If you opted-in for the New Architecture, we enable the Fabric Renderer. - DefaultNewArchitectureEntryPoint.getFabricEnabled(), // fabricEnabled - // If you opted-in for the New Architecture, we enable Concurrent React (i.e. React 18). - DefaultNewArchitectureEntryPoint.getConcurrentReactEnabled() // concurrentRootEnabled - ); + DefaultNewArchitectureEntryPoint.getFabricEnabled()); } @Override diff --git a/android/build.gradle b/android/build.gradle index a0ebdfa18..956a0e272 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -7,10 +7,10 @@ buildscript { compileSdkVersion = 33 targetSdkVersion = 33 supportLibVersion = "33.0.0" - kotlinVersion = "1.7.21" - kotlin_version = "1.7.21" - firebaseVersion = "23.1.1" + kotlinVersion = "1.8.21" + kotlin_version = kotlinVersion RNNKotlinVersion = kotlinVersion + firebaseVersion = "23.3.1" // We use NDK 23 which has both M1 support and is the side-by-side NDK version from AGP. ndkVersion = "23.1.7779620" @@ -21,9 +21,9 @@ buildscript { google() } dependencies { - classpath("com.android.tools.build:gradle:7.3.1") + classpath("com.android.tools.build:gradle") classpath("com.facebook.react:react-native-gradle-plugin") - classpath('com.google.gms:google-services:4.3.15') + classpath('com.google.gms:google-services:4.4.0') classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") // NOTE: Do not place your application dependencies here; they belong diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar index 41d9927a4..943f0cbfa 100644 Binary files a/android/gradle/wrapper/gradle-wrapper.jar and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 567ad65ab..6ec1567a0 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.1-all.zip +networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-all.zip diff --git a/android/gradlew b/android/gradlew index a58591e97..65dcd68d6 100755 --- a/android/gradlew +++ b/android/gradlew @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,10 +80,10 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' @@ -143,12 +143,16 @@ fi if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -205,6 +209,12 @@ set -- \ org.gradle.wrapper.GradleWrapperMain \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. @@ -231,4 +241,4 @@ eval "set -- $( tr '\n' ' ' )" '"$@"' -exec "$JAVACMD" "$@" \ No newline at end of file +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat index 477c89664..93e3f59f1 100644 --- a/android/gradlew.bat +++ b/android/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,15 +76,17 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal -:omega \ No newline at end of file +:omega diff --git a/android/settings.gradle b/android/settings.gradle index 8fbfe0dbf..c7d391c3b 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -5,8 +5,6 @@ project(':reactnativenotifications').projectDir = new File(rootProject.projectDi apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings) include ':react-native-video' project(':react-native-video').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-video/android-exoplayer') -include ':watermelondb' -project(':watermelondb').projectDir = new File(rootProject.projectDir, '../node_modules/@nozbe/watermelondb/native/android') include ':watermelondb-jsi' project(':watermelondb-jsi').projectDir = new File(rootProject.projectDir, '../node_modules/@nozbe/watermelondb/native/android-jsi') -includeBuild('../node_modules/react-native-gradle-plugin') +includeBuild('../node_modules/@react-native/gradle-plugin') diff --git a/app/actions/local/category.test.ts b/app/actions/local/category.test.ts new file mode 100644 index 000000000..bdbe152a0 --- /dev/null +++ b/app/actions/local/category.test.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import DatabaseManager from '@database/manager'; + +import {handleConvertedGMCategories} from './category'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; + +describe('handleConvertedGMCategories', () => { + const serverUrl = 'baseHandler.test.com'; + const channelId = 'channel_id_1'; + const teamId1 = 'team_id_1'; + const teamId2 = 'team_id_2'; + const team: Team = { + id: teamId1, + } as Team; + + let operator: ServerDataOperator; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + operator = DatabaseManager.serverDatabases[serverUrl]!.operator; + }); + + it('base case', async () => { + await operator.handleTeam({teams: [team], prepareRecordsOnly: false}); + + const defaultCategory: Category = { + id: 'default_category_id', + team_id: teamId1, + type: 'channels', + } as Category; + + const customCategory: Category = { + id: 'custom_category_id', + team_id: teamId2, + type: 'custom', + } as Category; + + const dmCategory: Category = { + id: 'dm_category_id', + team_id: teamId1, + type: 'direct_messages', + } as Category; + + await operator.handleCategories({categories: [defaultCategory, customCategory, dmCategory], prepareRecordsOnly: false}); + + const dmCategoryChannel: CategoryChannel = { + id: 'dm_category_channel_id', + category_id: 'dm_category_id', + channel_id: channelId, + sort_order: 1, + }; + + const customCategoryChannel: CategoryChannel = { + id: 'custom_category_channel_id', + category_id: 'dm_category_id', + channel_id: channelId, + sort_order: 1, + }; + await operator.handleCategoryChannels({categoryChannels: [dmCategoryChannel, customCategoryChannel], prepareRecordsOnly: false}); + + const {models, error} = await handleConvertedGMCategories(serverUrl, channelId, teamId1, true); + expect(error).toBeUndefined(); + expect(models).toBeDefined(); + expect(models!.length).toBe(3); // two for removing channel for a custom and a DM category, and one for adding it to default channels category + }); +}); diff --git a/app/actions/local/category.ts b/app/actions/local/category.ts index f1f9eee7e..c390e0992 100644 --- a/app/actions/local/category.ts +++ b/app/actions/local/category.ts @@ -3,12 +3,13 @@ import {CHANNELS_CATEGORY, DMS_CATEGORY} from '@constants/categories'; import DatabaseManager from '@database/manager'; -import {prepareCategoryChannels, queryCategoriesByTeamIds, getCategoryById, prepareCategoriesAndCategoriesChannels} from '@queries/servers/categories'; +import {prepareCategoryChannels, queryCategoriesByTeamIds, getCategoryById, prepareCategoriesAndCategoriesChannels, queryCategoryChannelsByChannelId} from '@queries/servers/categories'; import {getCurrentUserId} from '@queries/servers/system'; import {queryMyTeams} from '@queries/servers/team'; import {isDMorGM} from '@utils/channel'; -import {logError} from '@utils/log'; +import {logDebug, logError} from '@utils/log'; +import type {Database, Model} from '@nozbe/watermelondb'; import type ChannelModel from '@typings/database/models/servers/channel'; export const deleteCategory = async (serverUrl: string, categoryId: string) => { @@ -91,11 +92,8 @@ export async function addChannelToDefaultCategory(serverUrl: string, channel: Ch categoriesWithChannels.push(cwc); } } else { - const categories = await queryCategoriesByTeamIds(database, [teamId]).fetch(); - const channelCategory = categories.find((c) => c.type === CHANNELS_CATEGORY); - if (channelCategory) { - const cwc = await channelCategory.toCategoryWithChannels(); - cwc.channel_ids.unshift(channel.id); + const cwc = await prepareAddNonGMDMChannelToDefaultCategory(database, teamId, channel.id); + if (cwc) { categoriesWithChannels.push(cwc); } } @@ -108,6 +106,62 @@ export async function addChannelToDefaultCategory(serverUrl: string, channel: Ch return {models}; } catch (error) { + logError('Failed to add channel to default category', error); + return {error}; + } +} + +async function prepareAddNonGMDMChannelToDefaultCategory(database: Database, teamId: string, channelId: string): Promise { + const categories = await queryCategoriesByTeamIds(database, [teamId]).fetch(); + const channelCategory = categories.find((category) => category.type === CHANNELS_CATEGORY); + if (channelCategory) { + const cwc = await channelCategory.toCategoryWithChannels(); + if (cwc.channel_ids.indexOf(channelId) < 0) { + cwc.channel_ids.unshift(channelId); + return cwc; + } + } + + return undefined; +} + +export async function handleConvertedGMCategories(serverUrl: string, channelId: string, targetTeamID: string, prepareRecordsOnly = false) { + try { + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const categoryChannels = await queryCategoryChannelsByChannelId(database, channelId).fetch(); + + const categories = await queryCategoriesByTeamIds(database, [targetTeamID]).fetch(); + const channelCategory = categories.find((category) => category.type === CHANNELS_CATEGORY); + + if (!channelCategory) { + const error = 'Failed to find default category when handling category of converted GM'; + logError(error); + return {error}; + } + + const models: Model[] = []; + + categoryChannels.forEach((categoryChannel) => { + if (categoryChannel.categoryId !== channelCategory.id) { + models.push(categoryChannel.prepareDestroyPermanently()); + } + }); + + const cwc = await prepareAddNonGMDMChannelToDefaultCategory(database, targetTeamID, channelId); + if (cwc) { + const model = await prepareCategoryChannels(operator, [cwc]); + models.push(...model); + } else { + logDebug('handleConvertedGMCategories: could not find channel category of target team'); + } + + if (models.length > 0 && !prepareRecordsOnly) { + await operator.batchRecords(models, 'putGMInCorrectCategory'); + } + + return {models}; + } catch (error) { + logError('Failed to handle category update for GM converted to channel', error); return {error}; } } diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 7f2ffb191..4eda3e3bf 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -4,7 +4,7 @@ /* eslint-disable max-lines */ import {DeviceEventEmitter} from 'react-native'; -import {addChannelToDefaultCategory, storeCategories} from '@actions/local/category'; +import {addChannelToDefaultCategory, handleConvertedGMCategories, storeCategories} from '@actions/local/category'; import {markChannelAsViewed, removeCurrentUserFromChannel, setChannelDeleteAt, storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel'; import {switchToGlobalThreads} from '@actions/local/thread'; import {loadCallForChannel} from '@calls/actions/calls'; @@ -231,6 +231,7 @@ export async function createChannel(serverUrl: string, displayName: string, purp const resolvedModels = await Promise.all(channelModels); models.push(...resolvedModels.flat()); } + const categoriesModels = await addChannelToDefaultCategory(serverUrl, channelData, true); if (categoriesModels.models?.length) { models.push(...categoriesModels.models); @@ -1262,3 +1263,56 @@ export const handleKickFromChannel = async (serverUrl: string, channelId: string logDebug('cannot kick user from channel', error); } }; + +export const fetchGroupMessageMembersCommonTeams = async (serverUrl: string, channelId: string) => { + try { + const client = NetworkManager.getClient(serverUrl); + const teams = await client.getGroupMessageMembersCommonTeams(channelId); + return {teams}; + } catch (error) { + logDebug('error on getGroupMessageMembersCommonTeams', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +}; + +export const convertGroupMessageToPrivateChannel = async (serverUrl: string, channelId: string, targetTeamId: string, displayName: string) => { + try { + const name = generateChannelNameFromDisplayName(displayName); + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + + const existingChannel = await getChannelById(database, channelId); + if (existingChannel) { + EphemeralStore.addConvertingChannel(channelId); + } + + const client = NetworkManager.getClient(serverUrl); + const updatedChannel = await client.convertGroupMessageToPrivateChannel(channelId, targetTeamId, displayName, name); + + if (existingChannel) { + existingChannel.prepareUpdate((channel) => { + channel.type = General.PRIVATE_CHANNEL; + channel.displayName = displayName; + channel.name = name; + channel.teamId = targetTeamId; + }); + + const models: Model[] = [existingChannel]; + + const {models: categoryUpdateModels} = await handleConvertedGMCategories(serverUrl, channelId, targetTeamId, true); + if (categoryUpdateModels) { + models.push(...categoryUpdateModels); + } + + await operator.batchRecords(models, 'convertGroupMessageToPrivateChannel'); + } + + return {updatedChannel}; + } catch (error) { + logError('error on convertGroupMessageToPrivateChannel', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } finally { + EphemeralStore.removeConvertingChannel(channelId); + } +}; diff --git a/app/actions/remote/reactions.ts b/app/actions/remote/reactions.ts index d710fc868..90b601350 100644 --- a/app/actions/remote/reactions.ts +++ b/app/actions/remote/reactions.ts @@ -16,6 +16,35 @@ import {forceLogoutIfNecessary} from './session'; import type {Model} from '@nozbe/watermelondb'; import type PostModel from '@typings/database/models/servers/post'; +export async function getIsReactionAlreadyAddedToPost(serverUrl: string, postId: string, emojiName: string) { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + + const currentUserId = await getCurrentUserId(database); + const emojiAlias = getEmojiFirstAlias(emojiName); + return await queryReaction(database, emojiAlias, postId, currentUserId).fetchCount() > 0; + } catch (error) { + logDebug('error on getIsReactionAlreadyAddedToPost', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +} + +export async function toggleReaction(serverUrl: string, postId: string, emojiName: string) { + try { + const isReactionAlreadyAddedToPost = await getIsReactionAlreadyAddedToPost(serverUrl, postId, emojiName); + + if (isReactionAlreadyAddedToPost) { + return removeReaction(serverUrl, postId, emojiName); + } + return addReaction(serverUrl, postId, emojiName); + } catch (error) { + logDebug('error on toggleReaction', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +} + export async function addReaction(serverUrl: string, postId: string, emojiName: string) { try { const client = NetworkManager.getClient(serverUrl); diff --git a/app/actions/websocket/channel.ts b/app/actions/websocket/channel.ts index 088920cbc..bb5d79902 100644 --- a/app/actions/websocket/channel.ts +++ b/app/actions/websocket/channel.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {addChannelToDefaultCategory} from '@actions/local/category'; +import {addChannelToDefaultCategory, handleConvertedGMCategories} from '@actions/local/category'; import { markChannelAsViewed, removeCurrentUserFromChannel, setChannelDeleteAt, storeMyChannelsForTeam, updateChannelInfoFromChannel, updateMyChannelFromWebsocket, @@ -12,10 +12,10 @@ import {fetchPostsForChannel} from '@actions/remote/post'; import {fetchRolesIfNeeded} from '@actions/remote/role'; import {fetchUsersByIds, updateUsersNoLongerVisible} from '@actions/remote/user'; import {loadCallForChannel} from '@calls/actions/calls'; -import {Events} from '@constants'; +import {Events, General} from '@constants'; import DatabaseManager from '@database/manager'; import {deleteChannelMembership, getChannelById, prepareMyChannelsForTeam, getCurrentChannel} from '@queries/servers/channel'; -import {getConfig, getCurrentChannelId} from '@queries/servers/system'; +import {getConfig, getCurrentChannelId, getCurrentTeamId, setCurrentTeamId} from '@queries/servers/system'; import {getCurrentUser, getTeammateNameDisplay, getUserById} from '@queries/servers/user'; import EphemeralStore from '@store/ephemeral_store'; import MyChannelModel from '@typings/database/models/servers/my_channel'; @@ -93,14 +93,35 @@ export async function handleChannelConvertedEvent(serverUrl: string, msg: any) { export async function handleChannelUpdatedEvent(serverUrl: string, msg: any) { try { const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const updatedChannel = JSON.parse(msg.data.channel) as Channel; + + if (EphemeralStore.isConvertingChannel(updatedChannel.id)) { + return; + } + + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const existingChannel = await getChannelById(database, updatedChannel.id); + const existingChannelType = existingChannel?.type; - const updatedChannel = JSON.parse(msg.data.channel); const models: Model[] = await operator.handleChannel({channels: [updatedChannel], prepareRecordsOnly: true}); const infoModel = await updateChannelInfoFromChannel(serverUrl, updatedChannel, true); if (infoModel.model) { models.push(...infoModel.model); } operator.batchRecords(models, 'handleChannelUpdatedEvent'); + + // This indicates a GM was converted to a private channel + if (existingChannelType === General.GM_CHANNEL && updatedChannel.type === General.PRIVATE_CHANNEL) { + await handleConvertedGMCategories(serverUrl, updatedChannel.id, updatedChannel.team_id); + + const currentChannelId = await getCurrentChannelId(database); + const currentTeamId = await getCurrentTeamId(database); + + // Making sure user is in the correct team + if (currentChannelId === updatedChannel.id && currentTeamId !== updatedChannel.team_id) { + await setCurrentTeamId(operator, updatedChannel.team_id); + } + } } catch { // Do nothing } diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index 51467e5aa..35c72dbbf 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -4,7 +4,12 @@ import {markChannelAsViewed} from '@actions/local/channel'; import {dataRetentionCleanup} from '@actions/local/systems'; import {markChannelAsRead} from '@actions/remote/channel'; -import {deferredAppEntryActions, entry, handleEntryAfterLoadNavigation, registerDeviceToken} from '@actions/remote/entry/common'; +import { + deferredAppEntryActions, + entry, + handleEntryAfterLoadNavigation, + registerDeviceToken, +} from '@actions/remote/entry/common'; import {fetchPostsForChannel, fetchPostThread} from '@actions/remote/post'; import {openAllUnreadChannels} from '@actions/remote/preference'; import {autoUpdateTimezone} from '@actions/remote/user'; @@ -17,9 +22,9 @@ import { handleCallRecordingState, handleCallScreenOff, handleCallScreenOn, - handleCallStarted, - handleCallUserConnected, - handleCallUserDisconnected, + handleCallStarted, handleCallUserConnected, handleCallUserDisconnected, + handleCallUserJoined, + handleCallUserLeft, handleCallUserMuted, handleCallUserRaiseHand, handleCallUserReacted, @@ -51,8 +56,14 @@ import {setTeamLoading} from '@store/team_load_store'; import {isTablet} from '@utils/helpers'; import {logDebug, logInfo} from '@utils/log'; -import {handleCategoryCreatedEvent, handleCategoryDeletedEvent, handleCategoryOrderUpdatedEvent, handleCategoryUpdatedEvent} from './category'; -import {handleChannelConvertedEvent, handleChannelCreatedEvent, +import { + handleCategoryCreatedEvent, + handleCategoryDeletedEvent, + handleCategoryOrderUpdatedEvent, + handleCategoryUpdatedEvent, +} from './category'; +import { + handleChannelConvertedEvent, handleChannelCreatedEvent, handleChannelDeletedEvent, handleChannelMemberUpdatedEvent, handleChannelUnarchiveEvent, @@ -61,15 +72,39 @@ import {handleChannelConvertedEvent, handleChannelCreatedEvent, handleMultipleChannelsViewedEvent, handleDirectAddedEvent, handleUserAddedToChannelEvent, - handleUserRemovedFromChannelEvent} from './channel'; -import {handleGroupMemberAddEvent, handleGroupMemberDeleteEvent, handleGroupReceivedEvent, handleGroupTeamAssociatedEvent, handleGroupTeamDissociateEvent} from './group'; + handleUserRemovedFromChannelEvent, +} from './channel'; +import { + handleGroupMemberAddEvent, + handleGroupMemberDeleteEvent, + handleGroupReceivedEvent, + handleGroupTeamAssociatedEvent, + handleGroupTeamDissociateEvent, +} from './group'; import {handleOpenDialogEvent} from './integrations'; -import {handleNewPostEvent, handlePostAcknowledgementAdded, handlePostAcknowledgementRemoved, handlePostDeleted, handlePostEdited, handlePostUnread} from './posts'; -import {handlePreferenceChangedEvent, handlePreferencesChangedEvent, handlePreferencesDeletedEvent} from './preferences'; +import { + handleNewPostEvent, + handlePostAcknowledgementAdded, + handlePostAcknowledgementRemoved, + handlePostDeleted, + handlePostEdited, + handlePostUnread, +} from './posts'; +import { + handlePreferenceChangedEvent, + handlePreferencesChangedEvent, + handlePreferencesDeletedEvent, +} from './preferences'; import {handleAddCustomEmoji, handleReactionRemovedFromPostEvent, handleReactionAddedToPostEvent} from './reactions'; import {handleUserRoleUpdatedEvent, handleTeamMemberRoleUpdatedEvent, handleRoleUpdatedEvent} from './roles'; import {handleLicenseChangedEvent, handleConfigChangedEvent} from './system'; -import {handleLeaveTeamEvent, handleUserAddedToTeamEvent, handleUpdateTeamEvent, handleTeamArchived, handleTeamRestored} from './teams'; +import { + handleLeaveTeamEvent, + handleUserAddedToTeamEvent, + handleUpdateTeamEvent, + handleTeamArchived, + handleTeamRestored, +} from './teams'; import {handleThreadUpdatedEvent, handleThreadReadChangedEvent, handleThreadFollowChangedEvent} from './threads'; import {handleUserUpdatedEvent, handleUserTypingEvent, handleStatusChangedEvent} from './users'; @@ -347,12 +382,23 @@ export async function handleEvent(serverUrl: string, msg: WebSocketMessage) { case WebsocketEvents.CALLS_CHANNEL_DISABLED: handleCallChannelDisabled(serverUrl, msg); break; + + // DEPRECATED in favour of user_joined (since v0.21.0) case WebsocketEvents.CALLS_USER_CONNECTED: handleCallUserConnected(serverUrl, msg); break; + + // DEPRECATED in favour of user_left (since v0.21.0) case WebsocketEvents.CALLS_USER_DISCONNECTED: handleCallUserDisconnected(serverUrl, msg); break; + + case WebsocketEvents.CALLS_USER_JOINED: + handleCallUserJoined(serverUrl, msg); + break; + case WebsocketEvents.CALLS_USER_LEFT: + handleCallUserLeft(serverUrl, msg); + break; case WebsocketEvents.CALLS_USER_MUTED: handleCallUserMuted(serverUrl, msg); break; diff --git a/app/client/rest/channels.ts b/app/client/rest/channels.ts index 1fd390b60..80577f973 100644 --- a/app/client/rest/channels.ts +++ b/app/client/rest/channels.ts @@ -44,6 +44,8 @@ export interface ClientChannelsMix { searchAllChannels: (term: string, teamIds: string[], archivedOnly?: boolean) => Promise; updateChannelMemberSchemeRoles: (channelId: string, userId: string, isSchemeUser: boolean, isSchemeAdmin: boolean) => Promise; getMemberInChannel: (channelId: string, userId: string) => Promise; + getGroupMessageMembersCommonTeams: (channelId: string) => Promise; + convertGroupMessageToPrivateChannel: (channelId: string, teamId: string, displayName: string, name: string) => Promise; } const ClientChannels = >(superclass: TBase) => class extends superclass { @@ -350,6 +352,27 @@ const ClientChannels = >(superclass: TBase {method: 'get'}, ); }; + + getGroupMessageMembersCommonTeams = (channelId: string) => { + return this.doFetch( + `${this.getChannelRoute(channelId)}/common_teams`, + {method: 'get'}, + ); + }; + + convertGroupMessageToPrivateChannel = (channelId: string, teamId: string, displayName: string, name: string) => { + const body = { + channel_id: channelId, + team_id: teamId, + display_name: displayName, + name, + }; + + return this.doFetch( + `${this.getChannelRoute(channelId)}/convert_to_channel?team-id=${teamId}`, + {method: 'post', body}, + ); + }; }; export default ClientChannels; diff --git a/app/components/announcement_banner/index.ts b/app/components/announcement_banner/index.ts index fd53e1343..e2f742121 100644 --- a/app/components/announcement_banner/index.ts +++ b/app/components/announcement_banner/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$, combineLatest} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/components/autocomplete/at_mention/index.ts b/app/components/autocomplete/at_mention/index.ts index b34b40e68..9be43422a 100644 --- a/app/components/autocomplete/at_mention/index.ts +++ b/app/components/autocomplete/at_mention/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$, combineLatest, Observable} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/components/autocomplete/channel_mention/index.ts b/app/components/autocomplete/channel_mention/index.ts index c9f731a42..5ec70430e 100644 --- a/app/components/autocomplete/channel_mention/index.ts +++ b/app/components/autocomplete/channel_mention/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {map, switchMap} from 'rxjs/operators'; diff --git a/app/components/autocomplete/emoji_suggestion/index.ts b/app/components/autocomplete/emoji_suggestion/index.ts index d816293d5..28435cff3 100644 --- a/app/components/autocomplete/emoji_suggestion/index.ts +++ b/app/components/autocomplete/emoji_suggestion/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/components/autocomplete/index.ts b/app/components/autocomplete/index.ts index d5b2f78d8..d5d537bbf 100644 --- a/app/components/autocomplete/index.ts +++ b/app/components/autocomplete/index.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import withObservables from '@nozbe/with-observables'; +import {withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import AppsManager from '@managers/apps_manager'; diff --git a/app/components/autocomplete/slash_suggestion/app_slash_suggestion/index.ts b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/index.ts index d1911188b..ee177f5c3 100644 --- a/app/components/autocomplete/slash_suggestion/app_slash_suggestion/index.ts +++ b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {observeConfigBooleanValue, observeCurrentTeamId} from '@queries/servers/system'; diff --git a/app/components/autocomplete/slash_suggestion/index.ts b/app/components/autocomplete/slash_suggestion/index.ts index c087e095b..92ceb7d59 100644 --- a/app/components/autocomplete/slash_suggestion/index.ts +++ b/app/components/autocomplete/slash_suggestion/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {observeCurrentTeamId} from '@queries/servers/system'; diff --git a/app/components/autocomplete_selector/index.tsx b/app/components/autocomplete_selector/index.tsx index 104279e7c..64f0842bb 100644 --- a/app/components/autocomplete_selector/index.tsx +++ b/app/components/autocomplete_selector/index.tsx @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import React, {useCallback, useEffect, useState} from 'react'; import {type IntlShape, useIntl} from 'react-intl'; import {Text, View} from 'react-native'; diff --git a/app/components/button/index.tsx b/app/components/button/index.tsx index 9aa6c60d4..eb8bde1c3 100644 --- a/app/components/button/index.tsx +++ b/app/components/button/index.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useMemo} from 'react'; +import React, {useMemo, type ReactNode} from 'react'; import {type StyleProp, StyleSheet, Text, type TextStyle, View, type ViewStyle} from 'react-native'; import RNButton from 'react-native-button'; @@ -21,6 +21,7 @@ type Props = ConditionalProps & { testID?: string; onPress: () => void; text: string; + iconComponent?: ReactNode; } const styles = StyleSheet.create({ @@ -41,6 +42,7 @@ const Button = ({ testID, iconName, iconSize, + iconComponent, }: Props) => { const bgStyle = useMemo(() => [ buttonBackgroundStyle(theme, size, emphasis, buttonType, buttonState), @@ -61,6 +63,21 @@ const Button = ({ [iconSize], ); + let icon: ReactNode; + + if (iconComponent) { + icon = iconComponent; + } else if (iconName) { + icon = ( + + ); + } + return ( - {Boolean(iconName) && - - } + {icon} { + const {formatMessage} = useIntl(); + + const goToConvertToPrivateChannel = useCallback(preventDoubleTap(async () => { + await dismissBottomSheet(); + const title = formatMessage({id: 'channel_info.convert_gm_to_channel.screen_title', defaultMessage: 'Convert to Private Channel'}); + goToScreen(Screens.CONVERT_GM_TO_CHANNEL, title, {channelId}); + }), [channelId]); + + return ( + + ); +}; + +export default ConvertToChannelLabel; diff --git a/app/components/channel_actions/copy_channel_link_box/index.ts b/app/components/channel_actions/copy_channel_link_box/index.ts index 23fffb2e2..d14ec47de 100644 --- a/app/components/channel_actions/copy_channel_link_box/index.ts +++ b/app/components/channel_actions/copy_channel_link_box/index.ts @@ -4,8 +4,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/components/channel_actions/copy_channel_link_option/index.ts b/app/components/channel_actions/copy_channel_link_option/index.ts index ee975ec53..7a14480b0 100644 --- a/app/components/channel_actions/copy_channel_link_option/index.ts +++ b/app/components/channel_actions/copy_channel_link_option/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/components/channel_actions/favorite_box/index.ts b/app/components/channel_actions/favorite_box/index.ts index 9c159d58c..a6a6e887e 100644 --- a/app/components/channel_actions/favorite_box/index.ts +++ b/app/components/channel_actions/favorite_box/index.ts @@ -1,11 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {combineLatestWith, switchMap} from 'rxjs/operators'; import {observeIsChannelFavorited} from '@queries/servers/categories'; diff --git a/app/components/channel_actions/index.ts b/app/components/channel_actions/index.ts index bff50d6d4..883011046 100644 --- a/app/components/channel_actions/index.ts +++ b/app/components/channel_actions/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/components/channel_actions/leave_channel_label/index.ts b/app/components/channel_actions/leave_channel_label/index.ts index d1d39d4d8..bda43db50 100644 --- a/app/components/channel_actions/leave_channel_label/index.ts +++ b/app/components/channel_actions/leave_channel_label/index.ts @@ -1,11 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {combineLatestWith, switchMap} from 'rxjs/operators'; diff --git a/app/components/channel_actions/manage_members_label/index.tsx b/app/components/channel_actions/manage_members_label/index.tsx index 207c7c370..e9830c745 100644 --- a/app/components/channel_actions/manage_members_label/index.tsx +++ b/app/components/channel_actions/manage_members_label/index.tsx @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/components/channel_actions/mute_box/index.ts b/app/components/channel_actions/mute_box/index.ts index 6d7091991..ce3d5c99b 100644 --- a/app/components/channel_actions/mute_box/index.ts +++ b/app/components/channel_actions/mute_box/index.ts @@ -1,11 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/components/channel_actions/set_header_box/index.ts b/app/components/channel_actions/set_header_box/index.ts index a8dd81e3c..b1c45950c 100644 --- a/app/components/channel_actions/set_header_box/index.ts +++ b/app/components/channel_actions/set_header_box/index.ts @@ -1,11 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap, distinctUntilChanged} from 'rxjs/operators'; diff --git a/app/components/channel_icon/dm_avatar/index.ts b/app/components/channel_icon/dm_avatar/index.ts index fee98c531..a18d0cea9 100644 --- a/app/components/channel_icon/dm_avatar/index.ts +++ b/app/components/channel_icon/dm_avatar/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/components/channel_item/custom_status/index.ts b/app/components/channel_item/custom_status/index.ts index 13a19c450..dde49c4c8 100644 --- a/app/components/channel_item/custom_status/index.ts +++ b/app/components/channel_item/custom_status/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/components/channel_item/index.ts b/app/components/channel_item/index.ts index 841cdd3fa..03b980e8b 100644 --- a/app/components/channel_item/index.ts +++ b/app/components/channel_item/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import React from 'react'; import {of as of$} from 'rxjs'; import {switchMap, distinctUntilChanged} from 'rxjs/operators'; diff --git a/app/components/common_post_options/copy_permalink_option/index.tsx b/app/components/common_post_options/copy_permalink_option/index.tsx index e598e6903..d9cb5d069 100644 --- a/app/components/common_post_options/copy_permalink_option/index.tsx +++ b/app/components/common_post_options/copy_permalink_option/index.tsx @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {combineLatest, of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/components/common_post_options/follow_thread_option/index.ts b/app/components/common_post_options/follow_thread_option/index.ts index 6439db5f3..90d7aab7c 100644 --- a/app/components/common_post_options/follow_thread_option/index.ts +++ b/app/components/common_post_options/follow_thread_option/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {observeTeamIdByThread} from '@queries/servers/thread'; diff --git a/app/components/connection_banner/index.ts b/app/components/connection_banner/index.ts index 5de70a428..524bf8618 100644 --- a/app/components/connection_banner/index.ts +++ b/app/components/connection_banner/index.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import withObservables from '@nozbe/with-observables'; +import {withObservables} from '@nozbe/watermelondb/react'; import {withServerUrl} from '@context/server'; import websocket_manager from '@managers/websocket_manager'; diff --git a/app/components/custom_status/custom_status_expiry.tsx b/app/components/custom_status/custom_status_expiry.tsx index 3d2c1ed79..400da88ca 100644 --- a/app/components/custom_status/custom_status_expiry.tsx +++ b/app/components/custom_status/custom_status_expiry.tsx @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import moment, {type Moment} from 'moment-timezone'; import React from 'react'; import {Text, type TextStyle} from 'react-native'; diff --git a/app/components/emoji/emoji.tsx b/app/components/emoji/emoji.tsx index 35917e65d..7c13fe0d6 100644 --- a/app/components/emoji/emoji.tsx +++ b/app/components/emoji/emoji.tsx @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import React from 'react'; import { Image, diff --git a/app/components/files/index.ts b/app/components/files/index.ts index 5945f9b62..339fa7ad1 100644 --- a/app/components/files/index.ts +++ b/app/components/files/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$, from as from$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/components/files_search/file_options/option_menus/index.tsx b/app/components/files_search/file_options/option_menus/index.tsx index 5412631b6..8bed07c1b 100644 --- a/app/components/files_search/file_options/option_menus/index.tsx +++ b/app/components/files_search/file_options/option_menus/index.tsx @@ -1,9 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; -import compose from 'lodash/fp/compose'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {observeConfigBooleanValue, observeCanDownloadFiles} from '@queries/servers/system'; @@ -18,7 +16,4 @@ const enhance = withObservables([], ({database}: WithDatabaseArgs) => { }; }); -export default compose( - withDatabase, - enhance, -)(OptionMenus); +export default withDatabase(enhance(OptionMenus)); diff --git a/app/components/loading/index.tsx b/app/components/loading/index.tsx index f0636c854..21b2f3169 100644 --- a/app/components/loading/index.tsx +++ b/app/components/loading/index.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import React from 'react'; -import {ActivityIndicator, type StyleProp, View, type ViewStyle} from 'react-native'; +import {ActivityIndicator, type StyleProp, View, type ViewStyle, Text, type TextStyle} from 'react-native'; import {useTheme} from '@context/theme'; @@ -11,9 +11,18 @@ type LoadingProps = { size?: number | 'small' | 'large'; color?: string; themeColor?: keyof Theme; + footerText?: string; + footerTextStyles?: TextStyle; } -const Loading = ({containerStyle, size, color, themeColor}: LoadingProps) => { +const Loading = ({ + containerStyle, + size, + color, + themeColor, + footerText, + footerTextStyles, +}: LoadingProps) => { const theme = useTheme(); const indicatorColor = themeColor ? theme[themeColor] : color; @@ -23,6 +32,10 @@ const Loading = ({containerStyle, size, color, themeColor}: LoadingProps) => { color={indicatorColor} size={size} /> + { + footerText && + {footerText} + } ); }; diff --git a/app/components/markdown/at_mention/at_mention.tsx b/app/components/markdown/at_mention/at_mention.tsx index e53d7683c..bdadc9775 100644 --- a/app/components/markdown/at_mention/at_mention.tsx +++ b/app/components/markdown/at_mention/at_mention.tsx @@ -11,24 +11,20 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'; import {fetchUserOrGroupsByMentionsInBatch} from '@actions/remote/user'; import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item'; import {Screens} from '@constants'; -import {MM_TABLES} from '@constants/database'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import GroupModel from '@database/models/server/group'; -import UserModel from '@database/models/server/user'; +import {useMemoMentionedGroup, useMemoMentionedUser} from '@hooks/markdown'; import {bottomSheet, dismissBottomSheet, openAsBottomSheet} from '@screens/navigation'; import {bottomSheetSnapPoint} from '@utils/helpers'; -import {displayUsername, getUsersByUsername} from '@utils/user'; +import {displayUsername} from '@utils/user'; -import type {Database} from '@nozbe/watermelondb'; -import type GroupModelType from '@typings/database/models/servers/group'; import type GroupMembershipModel from '@typings/database/models/servers/group_membership'; import type UserModelType from '@typings/database/models/servers/user'; type AtMentionProps = { channelId?: string; currentUserId: string; - database: Database; disableAtChannelMentionHighlight?: boolean; isSearchResult?: boolean; location: string; @@ -43,8 +39,6 @@ type AtMentionProps = { groupMemberships: GroupMembershipModel[]; } -const {SERVER: {GROUP, USER}} = MM_TABLES; - const style = StyleSheet.create({ bottomSheet: {flex: 1}, }); @@ -52,7 +46,6 @@ const style = StyleSheet.create({ const AtMention = ({ channelId, currentUserId, - database, disableAtChannelMentionHighlight, isSearchResult, location, @@ -72,31 +65,15 @@ const AtMention = ({ const {bottom} = useSafeAreaInsets(); const serverUrl = useServerUrl(); - const user = useMemo(() => { - const usersByUsername = getUsersByUsername(users); - let mn = mentionName.toLowerCase(); - - while (mn.length > 0) { - if (usersByUsername[mn]) { - return usersByUsername[mn]; - } - - // Repeatedly trim off trailing punctuation in case this is at the end of a sentence - if ((/[._-]$/).test(mn)) { - mn = mn.substring(0, mn.length - 1); - } else { - break; - } - } - - // @ts-expect-error: The model constructor is hidden within WDB type definition - return new UserModel(database.get(USER), {username: ''}); - }, [users, mentionName]); + const user = useMemoMentionedUser(users, mentionName); const userMentionKeys = useMemo(() => { if (mentionKeys) { return mentionKeys; } + if (!user) { + return []; + } if (user.id !== currentUserId) { return []; @@ -105,50 +82,21 @@ const AtMention = ({ return user.mentionKeys; }, [currentUserId, mentionKeys, user]); - // Checks if the mention is a group - const group = useMemo(() => { - if (user?.username) { - return undefined; - } - const getGroupsByName = (gs: GroupModelType[]) => { - const groupsByName: Dictionary = {}; - - for (const g of gs) { - groupsByName[g.name] = g; - } - - return groupsByName; - }; - - const groupsByName = getGroupsByName(groups); - let mn = mentionName.toLowerCase(); - - while (mn.length > 0) { - if (groupsByName[mn]) { - return groupsByName[mn]; - } - - // Repeatedly trim off trailing punctuation in case this is at the end of a sentence - if ((/[._-]$/).test(mn)) { - mn = mn.substring(0, mn.length - 1); - } else { - break; - } - } - - // @ts-expect-error: The model constructor is hidden within WDB type definition - return new GroupModel(database.get(GROUP), {name: ''}); - }, [groups, user, mentionName]); + const group = useMemoMentionedGroup(groups, user, mentionName); // Effects useEffect(() => { // Fetches and updates the local db store with the mention - if (!user.username && !group?.name) { + if (!user?.username && !group?.name) { fetchUserOrGroupsByMentionsInBatch(serverUrl, mentionName); } }, []); const openUserProfile = () => { + if (!user) { + return; + } + const screen = Screens.USER_PROFILE; const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'}); const closeButtonId = 'close-user-profile'; @@ -171,7 +119,7 @@ const AtMention = ({ onPress={() => { dismissBottomSheet(); let username = mentionName; - if (user.username) { + if (user?.username) { username = user.username; } diff --git a/app/components/markdown/at_mention/index.ts b/app/components/markdown/at_mention/index.ts index 7713b1d86..39fefde54 100644 --- a/app/components/markdown/at_mention/index.ts +++ b/app/components/markdown/at_mention/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {switchMap} from 'rxjs/operators'; import {queryGroupsByName, queryGroupMembershipForMember} from '@queries/servers/group'; diff --git a/app/components/markdown/channel_mention/index.ts b/app/components/markdown/channel_mention/index.ts index 274c66de2..6843061f7 100644 --- a/app/components/markdown/channel_mention/index.ts +++ b/app/components/markdown/channel_mention/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {switchMap} from 'rxjs/operators'; import {queryAllChannelsForTeam} from '@queries/servers/channel'; diff --git a/app/components/markdown/index.ts b/app/components/markdown/index.ts index fca59ee53..6674a8af2 100644 --- a/app/components/markdown/index.ts +++ b/app/components/markdown/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import React from 'react'; import {observeConfigBooleanValue, observeConfigIntValue} from '@queries/servers/system'; diff --git a/app/components/markdown/markdown.tsx b/app/components/markdown/markdown.tsx index 36bcd7f04..f0efcfeb3 100644 --- a/app/components/markdown/markdown.tsx +++ b/app/components/markdown/markdown.tsx @@ -29,11 +29,11 @@ import MarkdownTable from './markdown_table'; import MarkdownTableCell, {type MarkdownTableCellProps} from './markdown_table_cell'; import MarkdownTableImage from './markdown_table_image'; import MarkdownTableRow, {type MarkdownTableRowProps} from './markdown_table_row'; -import {addListItemIndices, combineTextNodes, highlightMentions, highlightSearchPatterns, parseTaskLists, pullOutImages} from './transform'; +import {addListItemIndices, combineTextNodes, highlightMentions, highlightWithoutNotification, highlightSearchPatterns, parseTaskLists, pullOutImages} from './transform'; import type { MarkdownAtMentionRenderer, MarkdownBaseRenderer, MarkdownBlockStyles, MarkdownChannelMentionRenderer, - MarkdownEmojiRenderer, MarkdownImageRenderer, MarkdownLatexRenderer, MarkdownTextStyles, SearchPattern, UserMentionKey, + MarkdownEmojiRenderer, MarkdownImageRenderer, MarkdownLatexRenderer, MarkdownTextStyles, SearchPattern, UserMentionKey, HighlightWithoutNotificationKey, } from '@typings/global/markdown'; type MarkdownProps = { @@ -55,6 +55,7 @@ type MarkdownProps = { disableTables?: boolean; enableLatex: boolean; enableInlineLatex: boolean; + highlightKeys?: HighlightWithoutNotificationKey[]; imagesMetadata?: Record; isEdited?: boolean; isReplyPost?: boolean; @@ -133,7 +134,7 @@ const Markdown = ({ disableCodeBlock, disableGallery, disableHashtags, disableHeading, disableTables, enableInlineLatex, enableLatex, maxNodes, imagesMetadata, isEdited, isReplyPost, isSearchResult, layoutHeight, layoutWidth, - location, mentionKeys, minimumHashtagLength = 3, onPostPress, postId, searchPatterns, + location, mentionKeys, highlightKeys, minimumHashtagLength = 3, onPostPress, postId, searchPatterns, textStyles = {}, theme, value = '', baseParagraphStyle, onLinkLongPress, }: MarkdownProps) => { const style = getStyleSheet(theme); @@ -578,6 +579,7 @@ const Markdown = ({ mention_highlight: Renderer.forwardChildren, search_highlight: Renderer.forwardChildren, + highlight_without_notification: Renderer.forwardChildren, checkbox: renderCheckbox, editedIndicator: renderEditedIndicator, @@ -604,10 +606,12 @@ const Markdown = ({ if (mentionKeys) { ast = highlightMentions(ast, mentionKeys); } + if (highlightKeys) { + ast = highlightWithoutNotification(ast, highlightKeys); + } if (searchPatterns) { ast = highlightSearchPatterns(ast, searchPatterns); } - if (isEdited) { const editIndicatorNode = new Node('edited_indicator'); if (ast.lastChild && ['heading', 'paragraph'].includes(ast.lastChild.type)) { diff --git a/app/components/markdown/markdown_link/index.ts b/app/components/markdown/markdown_link/index.ts index 3c5747856..f24be23ac 100644 --- a/app/components/markdown/markdown_link/index.ts +++ b/app/components/markdown/markdown_link/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {observeConfigValue} from '@queries/servers/system'; diff --git a/app/components/markdown/transform.test.ts b/app/components/markdown/transform.test.ts index 1e3efff64..bcecd4c97 100644 --- a/app/components/markdown/transform.test.ts +++ b/app/components/markdown/transform.test.ts @@ -13,6 +13,8 @@ import { highlightTextNode, mentionKeysToPatterns, pullOutImages, + highlightWithoutNotification, + highlightKeysToPatterns, } from '@components/markdown/transform'; import {logError} from '@utils/log'; @@ -2602,7 +2604,7 @@ describe('Components.Markdown.transform', () => { } }); - describe('getFirstMention', () => { + describe('getFirstMention with mentionKeysToPatterns', () => { const tests = [{ name: 'no mention keys', input: 'apple banana orange', @@ -2801,6 +2803,461 @@ describe('Components.Markdown.transform', () => { }); } }); + + describe('highlightWithoutNotification', () => { + const tests = [{ + name: 'no highlights', + input: 'Cant put down an anti gravity book', + highlightKeys: [], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'Cant put down an anti gravity book', + }], + }], + }, + }, { + name: 'key bigger than input', + input: 'incredible', + highlightKeys: [{key: 'incredible and industructable'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'incredible', + }], + }], + }, + }, { + name: 'key part of the word', + input: 'Sesquipedalian', + highlightKeys: [{key: 'quipedalian'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'Sesquipedalian', + }], + }], + }, + }, { + name: 'word part of key', + input: 'floccinauc', + highlightKeys: [{key: 'floccinaucinihilipilification'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'floccinauc', + }], + }], + }, + }, { + name: 'a word highlight', + input: 'Cant put down an anti gravity book', + highlightKeys: [{key: 'anti'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [ + { + type: 'text', + literal: 'Cant put down an ', + }, + { + type: 'highlight_without_notification', + children: [{ + type: 'text', + literal: 'anti', + }], + }, + { + type: 'text', + literal: ' gravity book', + }, + ], + }], + }, + }, { + name: 'a sentence highlight', + input: 'Cant put down an anti gravity book', + highlightKeys: [{key: 'anti gravity'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [ + { + type: 'text', + literal: 'Cant put down an ', + }, + { + type: 'highlight_without_notification', + children: [{ + type: 'text', + literal: 'anti gravity', + }], + }, + { + type: 'text', + literal: ' book', + }, + ], + }], + }, + }, { + name: 'insensitive keywords', + input: 'Cant put down an anti gravity book', + highlightKeys: [{key: 'dOwN'}, {key: 'Anti'}, {key: 'BOOK'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [ + { + type: 'text', + literal: 'Cant put ', + }, + { + type: 'highlight_without_notification', + children: [{ + type: 'text', + literal: 'down', + }], + }, + { + type: 'text', + literal: ' an ', + }, + { + type: 'highlight_without_notification', + children: [{ + type: 'text', + literal: 'anti', + }], + }, + { + type: 'text', + literal: ' gravity ', + }, + { + type: 'highlight_without_notification', + children: [{ + type: 'text', + literal: 'book', + }], + }, + ], + }], + }, + }, { + name: 'insensitive keywords', + input: 'Cant put down an anti gravity book', + highlightKeys: [{key: 'dOwN'}, {key: 'Anti'}, {key: 'BOOK'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [ + { + type: 'text', + literal: 'Cant put ', + }, + { + type: 'highlight_without_notification', + children: [{ + type: 'text', + literal: 'down', + }], + }, + { + type: 'text', + literal: ' an ', + }, + { + type: 'highlight_without_notification', + children: [{ + type: 'text', + literal: 'anti', + }], + }, + { + type: 'text', + literal: ' gravity ', + }, + { + type: 'highlight_without_notification', + children: [{ + type: 'text', + literal: 'book', + }], + }, + ], + }], + }, + }, { + name: 'words with characters surrounding them', + input: 'peace& ^peace -peace-', + highlightKeys: [{key: 'PEACE'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [ + { + type: 'highlight_without_notification', + children: [{ + type: 'text', + literal: 'peace', + }], + }, + { + type: 'text', + literal: '& ^', + }, + { + type: 'highlight_without_notification', + children: [{ + type: 'text', + literal: 'peace', + }], + }, + { + type: 'text', + literal: ' -', + }, + { + type: 'highlight_without_notification', + children: [{ + type: 'text', + literal: 'peace', + }], + }, + { + type: 'text', + literal: '-', + }, + ], + }], + }, + }, { + name: 'input in code block', + input: '```\nTurning it off and\non\n```', + highlightKeys: [{key: 'words'}], + expected: { + type: 'document', + children: [{ + type: 'code_block', + literal: 'Turning it off and\non\n', + }], + }, + }, { + name: 'key in bold', + input: 'Actions speak **louder** than words', + highlightKeys: [{key: 'louder'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'Actions speak ', + }, { + type: 'strong', + children: [{ + type: 'highlight_without_notification', + children: [{ + type: 'text', + literal: 'louder', + }], + }], + }, { + type: 'text', + literal: ' than words', + }], + }], + }, + }, { + name: 'key in italic', + input: 'Actions speak *louder* than words', + highlightKeys: [{key: 'louder'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'Actions speak ', + }, { + type: 'emph', + children: [{ + type: 'highlight_without_notification', + children: [{ + type: 'text', + literal: 'louder', + }], + }], + }, { + type: 'text', + literal: ' than words', + }], + }], + }, + }, { + name: 'key in heading', + input: '### Actions speak louder than words', + highlightKeys: [{key: 'Actions'}], + expected: { + type: 'document', + children: [{ + type: 'heading', + level: 3, + children: [ + { + type: 'highlight_without_notification', + children: [{ + type: 'text', + literal: 'Actions', + }], + }, + { + type: 'text', + literal: ' speak louder than words', + }], + }], + }, + }, { + name: 'Do not mention partial keys', + input: 'Adding more memory wont help @bob', + highlightKeys: [{key: 'bob'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'Adding more memory wont help ', + }, { + type: 'at_mention', + _mentionName: 'bob', + }], + }], + }, + }, { + name: 'CJK word highlight', + input: '我确实喜欢我的同事。', + highlightKeys: [{key: '喜欢'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [ + { + type: 'text', + literal: '我确实', + }, + { + type: 'highlight_without_notification', + children: [{ + type: 'text', + literal: '喜欢', + }], + }, + { + type: 'text', + literal: '我的同事。', + }, + ], + }], + }, + }]; + + for (const test of tests) { + it(test.name, () => { + const input = combineTextNodes(parser.parse(test.input)); + const expected = makeAst(test.expected); + const actual = highlightWithoutNotification(input, test.highlightKeys); + + assert.ok(verifyAst(actual)); + assert.deepStrictEqual(stripUnusedFields(actual), stripUnusedFields(expected)); + }); + } + }); + + describe('getFirstMatch with highlightKeysToPatterns', () => { + const tests = [{ + name: 'text with space before', + input: ' lol', + patterns: highlightKeysToPatterns([{key: 'lol'}]), + expected: {index: 1, length: 3}, + }, + { + name: 'text with space afterwards and before', + input: ' Lol ', + patterns: highlightKeysToPatterns([{key: 'lol'}]), + expected: {index: 1, length: 3}, + }, + { + name: 'text with a non word character before (?)', + input: '?Lol', + patterns: highlightKeysToPatterns([{key: 'lol'}]), + expected: {index: 1, length: 3}, + }, + { + name: 'text with a non word character before (.)', + input: '?Lol', + patterns: highlightKeysToPatterns([{key: 'lol'}]), + expected: {index: 1, length: 3}, + }, + { + name: 'text with a non word character before (_)', + input: '?Lol', + patterns: highlightKeysToPatterns([{key: 'lol'}]), + expected: {index: 1, length: 3}, + }, + { + name: 'text with non word character after (.)', + input: 'Lol.', + patterns: highlightKeysToPatterns([{key: 'lol'}]), + expected: {index: 0, length: 3}, + }, + { + name: 'text with non word character after (?)', + input: 'Lol?', + patterns: highlightKeysToPatterns([{key: 'lol'}]), + expected: {index: 0, length: 3}, + }, + { + name: 'text with non word character after (_)', + input: 'Lol_', + patterns: highlightKeysToPatterns([{key: 'lol'}]), + expected: {index: 0, length: 3}, + }, + { + name: 'text with non word character before and after', + input: '?Lol?', + patterns: highlightKeysToPatterns([{key: 'lol'}]), + expected: {index: 1, length: 3}, + }]; + + for (const test of tests) { + it(test.name, () => { + const actual = getFirstMatch(test.input, test.patterns); + + assert.deepStrictEqual(actual, test.expected); + }); + } + }); }); // Testing and debugging functions diff --git a/app/components/markdown/transform.ts b/app/components/markdown/transform.ts index bf32d0c57..20b4bfd8a 100644 --- a/app/components/markdown/transform.ts +++ b/app/components/markdown/transform.ts @@ -5,7 +5,7 @@ import {Node, type NodeType} from 'commonmark'; import {escapeRegex} from '@utils/markdown'; -import type {SearchPattern, UserMentionKey} from '@typings/global/markdown'; +import type {HighlightWithoutNotificationKey, SearchPattern, UserMentionKey} from '@typings/global/markdown'; /* eslint-disable no-underscore-dangle */ @@ -174,6 +174,55 @@ export function mentionKeysToPatterns(mentionKeys: UserMentionKey[]) { }); } +export function highlightWithoutNotification(ast: Node, highlightKeys: HighlightWithoutNotificationKey[]) { + const walker = ast.walker(); + + const patterns = highlightKeysToPatterns(highlightKeys); + + let e; + while ((e = walker.next())) { + if (!e.entering) { + continue; + } + + const node = e.node; + if (node.type === 'text' && node.literal) { + const {index, length} = getFirstMatch(node.literal, patterns); + + // If the text node doesn't match any of the patterns, skip the loop + if (index === -1) { + continue; + } + + const matchNode = highlightTextNode(node, index, index + length, 'highlight_without_notification'); + + // Resume processing on the next node after the match node which may include any remaining text + // that was part of this one + walker.resumeAt(matchNode, false); + } + } + return ast; +} + +export function highlightKeysToPatterns(highlightKeys: HighlightWithoutNotificationKey[]) { + if (highlightKeys.length === 0) { + return []; + } + + return highlightKeys. + filter((highlight) => highlight.key.trim() !== ''). + sort((a, b) => b.key.length - a.key.length). + map(({key}) => { + if (cjkPattern.test(key)) { + // If the key contains Chinese, Japanese, Korean or Russian characters, don't mark word boundaries + return new RegExp(`${escapeRegex(key)}`, 'gi'); + } + + // If the key contains only English characters, mark word boundaries + return new RegExp(`(^|\\b)(${escapeRegex(key)})(?=_*\\b)`, 'gi'); + }); +} + export function highlightSearchPatterns(ast: Node, searchPatterns: SearchPattern[]) { const walker = ast.walker(); @@ -212,14 +261,22 @@ export function getFirstMatch(str: string, patterns: RegExp[]) { let firstMatchLength = -1; for (const pattern of patterns) { - const match = pattern.exec(str); - if (!match || match[0] === '') { + let matchResult; + if (pattern.global || pattern.sticky) { + // Since regex objects are stateful in global or sticky flags, we need to reset + const regex = new RegExp(pattern.source, pattern.flags); + matchResult = regex.exec(str); + } else { + matchResult = pattern.exec(str); + } + + if (!matchResult || matchResult[0] === '') { continue; } - if (firstMatchIndex === -1 || match.index < firstMatchIndex) { - firstMatchIndex = match.index; - firstMatchLength = match[0].length; + if (firstMatchIndex === -1 || matchResult.index < firstMatchIndex) { + firstMatchIndex = matchResult.index; + firstMatchLength = matchResult[0].length; } } diff --git a/app/components/option_item/index.tsx b/app/components/option_item/index.tsx index 641ade7ec..28c59adf3 100644 --- a/app/components/option_item/index.tsx +++ b/app/components/option_item/index.tsx @@ -43,10 +43,14 @@ const hitSlop = {top: 11, bottom: 11, left: 11, right: 11}; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { actionContainer: { + flex: 1, flexDirection: 'row', alignItems: 'center', marginLeft: 16, }, + actionSubContainer: { + marginLeft: 'auto', + }, container: { flexDirection: 'row', alignItems: 'center', @@ -61,8 +65,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { marginTop: DESCRIPTION_MARGIN_TOP, }, iconContainer: {marginRight: 16}, - infoContainer: {marginRight: 2}, info: { + flex: 1, + textAlign: 'right', color: changeOpacity(theme.centerChannelColor, 0.56), ...typography('Body', 100), }, @@ -99,7 +104,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { ...typography('Body', 200), }, row: { - flex: 1, + flex: 3, flexDirection: 'row', }, }; @@ -265,6 +270,7 @@ const OptionItem = ({ {label} @@ -284,16 +290,17 @@ const OptionItem = ({ { Boolean(info) && - - - {info} - - + + {info} + } - {actionComponent} + + {actionComponent} + } diff --git a/app/components/post_draft/draft_handler/index.ts b/app/components/post_draft/draft_handler/index.ts index 0f465286e..bf8eb7d2d 100644 --- a/app/components/post_draft/draft_handler/index.ts +++ b/app/components/post_draft/draft_handler/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import React from 'react'; import {DEFAULT_SERVER_MAX_FILE_SIZE} from '@constants/post_draft'; diff --git a/app/components/post_draft/index.ts b/app/components/post_draft/index.ts index 45b0626e3..e6d58669a 100644 --- a/app/components/post_draft/index.ts +++ b/app/components/post_draft/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import React from 'react'; import {combineLatest, of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/components/post_draft/post_input/index.ts b/app/components/post_draft/post_input/index.ts index 48b162d38..ecd458f9e 100644 --- a/app/components/post_draft/post_input/index.ts +++ b/app/components/post_draft/post_input/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import React from 'react'; import {of as of$} from 'rxjs'; import {switchMap, distinctUntilChanged} from 'rxjs/operators'; diff --git a/app/components/post_draft/quick_actions/index.ts b/app/components/post_draft/quick_actions/index.ts index 957923a72..ed39b2ff0 100644 --- a/app/components/post_draft/quick_actions/index.ts +++ b/app/components/post_draft/quick_actions/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import React from 'react'; import {observeIsPostPriorityEnabled} from '@queries/servers/post'; diff --git a/app/components/post_draft/send_handler/index.ts b/app/components/post_draft/send_handler/index.ts index 2de5561d4..8c420be15 100644 --- a/app/components/post_draft/send_handler/index.ts +++ b/app/components/post_draft/send_handler/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {combineLatest, of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/components/post_list/combined_user_activity/index.ts b/app/components/post_list/combined_user_activity/index.ts index 32842e06e..5ad70b513 100644 --- a/app/components/post_list/combined_user_activity/index.ts +++ b/app/components/post_list/combined_user_activity/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import React from 'react'; import {combineLatest, of as of$} from 'rxjs'; import {map, switchMap} from 'rxjs/operators'; diff --git a/app/components/post_list/index.ts b/app/components/post_list/index.ts index ce6a541da..ee0381eef 100644 --- a/app/components/post_list/index.ts +++ b/app/components/post_list/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import React from 'react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/components/post_list/more_messages/index.ts b/app/components/post_list/more_messages/index.ts index 507874da7..8267d872e 100644 --- a/app/components/post_list/more_messages/index.ts +++ b/app/components/post_list/more_messages/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import React from 'react'; import {of as of$, first as first$} from 'rxjs'; import {distinctUntilChanged, switchMap} from 'rxjs/operators'; diff --git a/app/components/post_list/post/avatar/index.ts b/app/components/post_list/post/avatar/index.ts index 659a8e997..40d7f574b 100644 --- a/app/components/post_list/post/avatar/index.ts +++ b/app/components/post_list/post/avatar/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import enhance from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {observePostAuthor} from '@queries/servers/post'; import {observeConfigBooleanValue} from '@queries/servers/system'; @@ -12,7 +11,7 @@ import Avatar from './avatar'; import type {WithDatabaseArgs} from '@typings/database/database'; import type PostModel from '@typings/database/models/servers/post'; -const withPost = enhance(['post'], ({database, post}: {post: PostModel} & WithDatabaseArgs) => { +const withPost = withObservables(['post'], ({database, post}: {post: PostModel} & WithDatabaseArgs) => { const enablePostIconOverride = observeConfigBooleanValue(database, 'EnablePostIconOverride'); return { diff --git a/app/components/post_list/post/body/acknowledgements/acknowledgements.tsx b/app/components/post_list/post/body/acknowledgements/acknowledgements.tsx index 68abe08e8..941d33d2b 100644 --- a/app/components/post_list/post/body/acknowledgements/acknowledgements.tsx +++ b/app/components/post_list/post/body/acknowledgements/acknowledgements.tsx @@ -22,7 +22,6 @@ import {typography} from '@utils/typography'; import UsersList from './users_list'; import {USER_ROW_HEIGHT} from './users_list/user_list_item'; -import type {BottomSheetProps} from '@gorhom/bottom-sheet'; import type PostModel from '@typings/database/models/servers/post'; import type UserModel from '@typings/database/models/servers/user'; @@ -146,7 +145,7 @@ const Acknowledgements = ({currentUserId, currentUserTimezone, hasReactions, loc const snapPoint1 = bottomSheetSnapPoint(Math.min(userIds.length, 5), USER_ROW_HEIGHT, bottom) + TITLE_HEIGHT; const snapPoint2 = height * 0.8; - const snapPoints: BottomSheetProps['snapPoints'] = [1, Math.min(snapPoint1, snapPoint2)]; + const snapPoints: number[] = [1, Math.min(snapPoint1, snapPoint2)]; if (userIds.length > 5 && snapPoint1 < snapPoint2) { snapPoints.push(snapPoint2); } diff --git a/app/components/post_list/post/body/acknowledgements/index.ts b/app/components/post_list/post/body/acknowledgements/index.ts index ad60590f2..934c70424 100644 --- a/app/components/post_list/post/body/acknowledgements/index.ts +++ b/app/components/post_list/post/body/acknowledgements/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import compose from 'lodash/fp/compose'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/components/post_list/post/body/acknowledgements/users_list/index.ts b/app/components/post_list/post/body/acknowledgements/users_list/index.ts index 9ae05d9f0..589d46d81 100644 --- a/app/components/post_list/post/body/acknowledgements/users_list/index.ts +++ b/app/components/post_list/post/body/acknowledgements/users_list/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {queryUsersById} from '@queries/servers/user'; diff --git a/app/components/post_list/post/body/add_members/index.ts b/app/components/post_list/post/body/add_members/index.ts index 159fa04ba..6071c9bb9 100644 --- a/app/components/post_list/post/body/add_members/index.ts +++ b/app/components/post_list/post/body/add_members/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/components/post_list/post/body/content/embedded_bindings/button_binding/index.tsx b/app/components/post_list/post/body/content/embedded_bindings/button_binding/index.tsx index 67a990cb5..c8704d66d 100644 --- a/app/components/post_list/post/body/content/embedded_bindings/button_binding/index.tsx +++ b/app/components/post_list/post/body/content/embedded_bindings/button_binding/index.tsx @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import React, {useCallback, useRef} from 'react'; import {useIntl} from 'react-intl'; import Button from 'react-native-button'; diff --git a/app/components/post_list/post/body/content/embedded_bindings/menu_binding/index.tsx b/app/components/post_list/post/body/content/embedded_bindings/menu_binding/index.tsx index 1502be76a..a9bba709f 100644 --- a/app/components/post_list/post/body/content/embedded_bindings/menu_binding/index.tsx +++ b/app/components/post_list/post/body/content/embedded_bindings/menu_binding/index.tsx @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import React, {useCallback, useMemo, useState} from 'react'; import {map} from 'rxjs/operators'; diff --git a/app/components/post_list/post/body/content/image_preview/index.ts b/app/components/post_list/post/body/content/image_preview/index.ts index 4a4e68e17..226f634e6 100644 --- a/app/components/post_list/post/body/content/image_preview/index.ts +++ b/app/components/post_list/post/body/content/image_preview/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/components/post_list/post/body/content/opengraph/index.ts b/app/components/post_list/post/body/content/opengraph/index.ts index ea237a50c..c438e942c 100644 --- a/app/components/post_list/post/body/content/opengraph/index.ts +++ b/app/components/post_list/post/body/content/opengraph/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$, combineLatest} from 'rxjs'; import {Preferences} from '@constants'; diff --git a/app/components/post_list/post/body/message/index.ts b/app/components/post_list/post/body/message/index.ts index cb56ac2b6..23385e434 100644 --- a/app/components/post_list/post/body/message/index.ts +++ b/app/components/post_list/post/body/message/index.ts @@ -1,9 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; +import {observeIfHighlightWithoutNotificationHasLicense} from '@queries/servers/system'; import {observeCurrentUser} from '@queries/servers/user'; import Message from './message'; @@ -12,8 +12,10 @@ import type {WithDatabaseArgs} from '@typings/database/database'; const withMessageInput = withObservables([], ({database}: WithDatabaseArgs) => { const currentUser = observeCurrentUser(database); + const isHighlightWithoutNotificationLicensed = observeIfHighlightWithoutNotificationHasLicense(database); return { currentUser, + isHighlightWithoutNotificationLicensed, }; }); diff --git a/app/components/post_list/post/body/message/message.tsx b/app/components/post_list/post/body/message/message.tsx index cfb8df411..1e70425a3 100644 --- a/app/components/post_list/post/body/message/message.tsx +++ b/app/components/post_list/post/body/message/message.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useState} from 'react'; import {type LayoutChangeEvent, ScrollView, useWindowDimensions, View} from 'react-native'; import Animated from 'react-native-reanimated'; @@ -16,10 +16,11 @@ import ShowMoreButton from './show_more_button'; import type PostModel from '@typings/database/models/servers/post'; import type UserModel from '@typings/database/models/servers/user'; -import type {SearchPattern} from '@typings/global/markdown'; +import type {HighlightWithoutNotificationKey, SearchPattern, UserMentionKey} from '@typings/global/markdown'; type MessageProps = { currentUser?: UserModel; + isHighlightWithoutNotificationLicensed?: boolean; highlight: boolean; isEdited: boolean; isPendingOrFailed: boolean; @@ -33,6 +34,9 @@ type MessageProps = { const SHOW_MORE_HEIGHT = 54; +const EMPTY_MENTION_KEYS: UserMentionKey[] = []; +const EMPTY_HIGHLIGHT_KEYS: HighlightWithoutNotificationKey[] = []; + const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { messageContainer: { @@ -52,7 +56,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }; }); -const Message = ({currentUser, highlight, isEdited, isPendingOrFailed, isReplyPost, layoutWidth, location, post, searchPatterns, theme}: MessageProps) => { +const Message = ({currentUser, isHighlightWithoutNotificationLicensed, highlight, isEdited, isPendingOrFailed, isReplyPost, layoutWidth, location, post, searchPatterns, theme}: MessageProps) => { const [open, setOpen] = useState(false); const [height, setHeight] = useState(); const dimensions = useWindowDimensions(); @@ -62,10 +66,6 @@ const Message = ({currentUser, highlight, isEdited, isPendingOrFailed, isReplyPo const blockStyles = getMarkdownBlockStyles(theme); const textStyles = getMarkdownTextStyles(theme); - const mentionKeys = useMemo(() => { - return currentUser?.mentionKeys; - }, [currentUser]); - const onLayout = useCallback((event: LayoutChangeEvent) => setHeight(event.nativeEvent.layout.height), []); const onPress = () => setOpen(!open); @@ -96,7 +96,8 @@ const Message = ({currentUser, highlight, isEdited, isPendingOrFailed, isReplyPo postId={post.id} textStyles={textStyles} value={post.message} - mentionKeys={mentionKeys} + mentionKeys={currentUser?.mentionKeys ?? EMPTY_MENTION_KEYS} + highlightKeys={isHighlightWithoutNotificationLicensed ? (currentUser?.highlightKeys ?? EMPTY_HIGHLIGHT_KEYS) : EMPTY_HIGHLIGHT_KEYS} searchPatterns={searchPatterns} theme={theme} /> diff --git a/app/components/post_list/post/body/reactions/index.ts b/app/components/post_list/post/body/reactions/index.ts index f8f1f3236..98794f956 100644 --- a/app/components/post_list/post/body/reactions/index.ts +++ b/app/components/post_list/post/body/reactions/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {combineLatest, of as of$} from 'rxjs'; import {map, switchMap} from 'rxjs/operators'; diff --git a/app/components/post_list/post/body/reactions/reactions.tsx b/app/components/post_list/post/body/reactions/reactions.tsx index 9bda75844..2ca18c922 100644 --- a/app/components/post_list/post/body/reactions/reactions.tsx +++ b/app/components/post_list/post/body/reactions/reactions.tsx @@ -5,7 +5,7 @@ import React, {useCallback, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; import {Keyboard, TouchableOpacity} from 'react-native'; -import {addReaction, removeReaction} from '@actions/remote/reactions'; +import {addReaction, removeReaction, toggleReaction} from '@actions/remote/reactions'; import CompassIcon from '@components/compass_icon'; import {Screens} from '@constants'; import {MAX_ALLOWED_REACTIONS} from '@constants/emoji'; @@ -100,8 +100,8 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled, return {reactionsByName, highlightedReactions}; }, [sortedReactions, reactions]); - const handleAddReactionToPost = (emoji: string) => { - addReaction(serverUrl, postId, emoji); + const handleToggleReactionToPost = (emoji: string) => { + toggleReaction(serverUrl, postId, emoji); }; const handleAddReaction = useCallback(preventDoubleTap(() => { @@ -110,7 +110,7 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled, screen: Screens.EMOJI_PICKER, theme, title: formatMessage({id: 'mobile.post_info.add_reaction', defaultMessage: 'Add Reaction'}), - props: {onEmojiPress: handleAddReactionToPost}, + props: {onEmojiPress: handleToggleReactionToPost}, }); }), [formatMessage, theme]); diff --git a/app/components/post_list/post/footer/index.ts b/app/components/post_list/post/footer/index.ts index 6aec4e0df..75e227c4c 100644 --- a/app/components/post_list/post/footer/index.ts +++ b/app/components/post_list/post/footer/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {observeTeamIdByThread, queryThreadParticipants} from '@queries/servers/thread'; diff --git a/app/components/post_list/post/header/index.ts b/app/components/post_list/post/header/index.ts index d7cbd2994..e4c2c330b 100644 --- a/app/components/post_list/post/header/index.ts +++ b/app/components/post_list/post/header/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {map, switchMap} from 'rxjs/operators'; diff --git a/app/components/post_list/post/index.ts b/app/components/post_list/post/index.ts index f827d0924..02e25d452 100644 --- a/app/components/post_list/post/index.ts +++ b/app/components/post_list/post/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import React from 'react'; import {of as of$, combineLatest} from 'rxjs'; import {switchMap, distinctUntilChanged} from 'rxjs/operators'; diff --git a/app/components/post_list/post/system_message/index.tsx b/app/components/post_list/post/system_message/index.tsx index 09bac590d..c92d71537 100644 --- a/app/components/post_list/post/system_message/index.tsx +++ b/app/components/post_list/post/system_message/index.tsx @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {observeConfigBooleanValue} from '@queries/servers/system'; import {observeUser} from '@queries/servers/user'; diff --git a/app/components/post_list/post_list.tsx b/app/components/post_list/post_list.tsx index 7bd89b9a8..efafa2cbd 100644 --- a/app/components/post_list/post_list.tsx +++ b/app/components/post_list/post_list.tsx @@ -3,7 +3,7 @@ import {FlatList} from '@stream-io/flat-list-mvcp'; import React, {type ReactElement, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {DeviceEventEmitter, type ListRenderItemInfo, type NativeScrollEvent, type NativeSyntheticEvent, Platform, type StyleProp, StyleSheet, type ViewStyle} from 'react-native'; +import {DeviceEventEmitter, type ListRenderItemInfo, Platform, type StyleProp, StyleSheet, type ViewStyle} from 'react-native'; import Animated, {type AnimatedStyle} from 'react-native-reanimated'; import {fetchPosts, fetchPostThread} from '@actions/remote/post'; @@ -19,7 +19,6 @@ import {getDateForDateLine, preparePostList} from '@utils/post_list'; import {INITIAL_BATCH_TO_RENDER, SCROLL_POSITION_CONFIG, VIEWABILITY_CONFIG} from './config'; import MoreMessages from './more_messages'; -import PostListRefreshControl from './refresh_control'; import type {PostListItem, PostListOtherItem, ViewableItemsChanged, ViewableItemsChangedListenerEvent} from '@typings/components/post_list'; import type PostModel from '@typings/database/models/servers/post'; @@ -72,14 +71,6 @@ const styles = StyleSheet.create({ }, container: { flex: 1, - scaleY: -1, - }, - scale: { - ...Platform.select({ - android: { - scaleY: -1, - }, - }), }, }); @@ -116,7 +107,6 @@ const PostList = ({ const onScrollEndIndexListener = useRef(); const onViewableItemsChangedListener = useRef(); const scrolledToHighlighted = useRef(false); - const [enableRefreshControl, setEnableRefreshControl] = useState(false); const [refreshing, setRefreshing] = useState(false); const theme = useTheme(); const serverUrl = useServerUrl(); @@ -170,13 +160,6 @@ const PostList = ({ setRefreshing(false); }, [channelId, location, posts, rootId]); - const onScroll = useCallback((event: NativeSyntheticEvent) => { - if (Platform.OS === 'android') { - const {y} = event.nativeEvent.contentOffset; - setEnableRefreshControl(y === 0); - } - }, []); - const onScrollToIndexFailed = useCallback((info: ScrollIndexFailed) => { const index = Math.min(info.highestMeasuredFrameIndex, info.index); @@ -233,7 +216,6 @@ const PostList = ({ key={item.value} theme={theme} testID={`${testID}.new_messages_line`} - style={styles.scale} /> ); case 'date': @@ -241,7 +223,6 @@ const PostList = ({ ); @@ -251,7 +232,6 @@ const PostList = ({ key={item.value} rootId={rootId!} testID={`${testID}.thread_overview`} - style={styles.scale} /> ); case 'user-activity': { @@ -260,7 +240,7 @@ const PostList = ({ key: item.value, postId: item.value, location, - style: Platform.OS === 'ios' ? styles.scale : styles.container, + style: styles.container, testID: `${testID}.combined_user_activity`, showJoinLeave: shouldShowJoinLeaveMessages, theme, @@ -288,7 +268,6 @@ const PostList = ({ rootId, shouldRenderReplyButton, skipSaveddHeader, - style: styles.scale, testID: `${testID}.post`, }; @@ -328,40 +307,33 @@ const PostList = ({ return ( <> - - - + onRefresh={disablePullToRefresh ? undefined : onRefresh} + /> {showMoreMessages && ; id: string; - imageStyle?: StyleProp; + imageStyle?: StyleProp; isBackgroundImage?: boolean; onError: () => void; resizeMode?: ResizeMode; @@ -121,7 +123,7 @@ const ProgressiveImage = ({ image = ( ), animatedOpacity]} onLoadEnd={onLoadImageEnd} testID='progressive_image.highResImage' /> diff --git a/app/components/progressive_image/thumbnail.tsx b/app/components/progressive_image/thumbnail.tsx index 4bd51532d..ccc24be8f 100644 --- a/app/components/progressive_image/thumbnail.tsx +++ b/app/components/progressive_image/thumbnail.tsx @@ -3,7 +3,7 @@ import React from 'react'; import {Image, type ColorValue, type StyleProp, type ImageStyle} from 'react-native'; -import FastImage, {type Source} from 'react-native-fast-image'; +import FastImage, {type ImageStyle as FastImageStyle, type Source} from 'react-native-fast-image'; import Animated, {type SharedValue} from 'react-native-reanimated'; // @ts-expect-error FastImage does work with Animated.createAnimatedComponent @@ -25,9 +25,7 @@ const Thumbnail = ({onError, opacity, style, source, tintColor}: ThumbnailProps) onError={onError} resizeMode='cover' source={source} - - // @ts-expect-error style is supported but TS complains - style={style} + style={(style as StyleProp)} testID='progressive_image.miniPreview' /> ); diff --git a/app/components/remove_markdown/at_mention/at_mention.tsx b/app/components/remove_markdown/at_mention/at_mention.tsx new file mode 100644 index 000000000..c648a50d5 --- /dev/null +++ b/app/components/remove_markdown/at_mention/at_mention.tsx @@ -0,0 +1,69 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect} from 'react'; +import {type StyleProp, Text, type TextStyle} from 'react-native'; + +import {fetchUserOrGroupsByMentionsInBatch} from '@actions/remote/user'; +import {useServerUrl} from '@context/server'; +import GroupModel from '@database/models/server/group'; +import {useMemoMentionedGroup, useMemoMentionedUser} from '@hooks/markdown'; +import {displayUsername} from '@utils/user'; + +import type {Database} from '@nozbe/watermelondb'; +import type UserModelType from '@typings/database/models/servers/user'; + +type AtMentionProps = { + database: Database; + mentionName: string; + teammateNameDisplay: string; + textStyle?: StyleProp; + users: UserModelType[]; + groups: GroupModel[]; +} + +const AtMention = ({ + mentionName, + teammateNameDisplay, + textStyle, + users, + groups, +}: AtMentionProps) => { + const serverUrl = useServerUrl(); + + const user = useMemoMentionedUser(users, mentionName); + const group = useMemoMentionedGroup(groups, user, mentionName); + + // Effects + useEffect(() => { + // Fetches and updates the local db store with the mention + if (!user?.username && !group?.name) { + fetchUserOrGroupsByMentionsInBatch(serverUrl, mentionName); + } + }, []); + + let mention; + + if (user?.username) { + mention = displayUsername(user, user.locale, teammateNameDisplay); + } else if (group?.name) { + mention = group.name; + } else { + const pattern = new RegExp(/\b(all|channel|here)(?:\.\B|_\b|\b)/, 'i'); + const mentionMatch = pattern.exec(mentionName); + + if (mentionMatch) { + mention = mentionMatch.length > 1 ? mentionMatch[1] : mentionMatch[0]; + } else { + mention = mentionName; + } + } + + return ( + + {'@' + mention} + + ); +}; + +export default AtMention; diff --git a/app/components/remove_markdown/at_mention/index.ts b/app/components/remove_markdown/at_mention/index.ts new file mode 100644 index 000000000..482e2a9c2 --- /dev/null +++ b/app/components/remove_markdown/at_mention/index.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; + +import {queryGroupsByName} from '@queries/servers/group'; +import {observeTeammateNameDisplay, queryUsersLike} from '@queries/servers/user'; + +import AtMention from './at_mention'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +const enhance = withObservables(['mentionName'], ({database, mentionName}: {mentionName: string} & WithDatabaseArgs) => { + const teammateNameDisplay = observeTeammateNameDisplay(database); + + let mn = mentionName.toLowerCase(); + if ((/[._-]$/).test(mn)) { + mn = mn.substring(0, mn.length - 1); + } + + return { + teammateNameDisplay, + users: queryUsersLike(database, mn).observeWithColumns(['username']), + groups: queryGroupsByName(database, mn).observeWithColumns(['name']), + }; +}); + +export default withDatabase(enhance(AtMention)); diff --git a/app/components/remove_markdown/index.tsx b/app/components/remove_markdown/index.tsx index aa4cfb61d..af9a3e7e1 100644 --- a/app/components/remove_markdown/index.tsx +++ b/app/components/remove_markdown/index.tsx @@ -9,6 +9,8 @@ import {type StyleProp, Text, type TextStyle} from 'react-native'; import Emoji from '@components/emoji'; import {computeTextStyle} from '@utils/markdown'; +import AtMention from './at_mention'; + import type {MarkdownBaseRenderer, MarkdownEmojiRenderer, MarkdownTextStyles} from '@typings/global/markdown'; type Props = { @@ -45,6 +47,15 @@ const RemoveMarkdown = ({enableEmoji, enableHardBreak, enableSoftBreak, enableCo return {literal}; }, [baseStyle]); + const renderAtMention = ({context, mentionName}: {context: string[]; mentionName: string}) => { + return ( + + ); + }; + const renderCodeSpan = useCallback(({context, literal}: MarkdownBaseRenderer) => { if (!enableCodeSpan) { return renderText({literal}); @@ -76,7 +87,7 @@ const RemoveMarkdown = ({enableEmoji, enableHardBreak, enableSoftBreak, enableCo code: renderCodeSpan, link: Renderer.forwardChildren, image: renderNull, - atMention: Renderer.forwardChildren, + atMention: renderAtMention, channelLink: Renderer.forwardChildren, emoji: renderEmoji, hashtag: Renderer.forwardChildren, diff --git a/app/components/server_version/index.tsx b/app/components/server_version/index.tsx index f1c28a116..e119ee6e6 100644 --- a/app/components/server_version/index.tsx +++ b/app/components/server_version/index.tsx @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {useEffect} from 'react'; import {type IntlShape, useIntl} from 'react-intl'; import {distinctUntilChanged, map} from 'rxjs/operators'; diff --git a/app/components/system_header/index.tsx b/app/components/system_header/index.tsx index 07e25ff51..f15a75a7a 100644 --- a/app/components/system_header/index.tsx +++ b/app/components/system_header/index.tsx @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import React from 'react'; import {View} from 'react-native'; import {map} from 'rxjs/operators'; diff --git a/app/components/team_list/team_list_item/index.ts b/app/components/team_list/team_list_item/index.ts index 66ac27ccd..b6e69dcd6 100644 --- a/app/components/team_list/team_list_item/index.ts +++ b/app/components/team_list/team_list_item/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {observeCurrentUserId} from '@queries/servers/system'; diff --git a/app/components/team_sidebar/index.ts b/app/components/team_sidebar/index.ts index 8c4a8417d..022732188 100644 --- a/app/components/team_sidebar/index.ts +++ b/app/components/team_sidebar/index.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import withObservables from '@nozbe/with-observables'; +import {withObservables} from '@nozbe/watermelondb/react'; import {withServerUrl} from '@context/server'; import EphemeralStore from '@store/ephemeral_store'; diff --git a/app/components/team_sidebar/team_list/index.ts b/app/components/team_sidebar/team_list/index.ts index 20c0aae78..f8862828f 100644 --- a/app/components/team_sidebar/team_list/index.ts +++ b/app/components/team_sidebar/team_list/index.ts @@ -3,8 +3,7 @@ /* eslint-disable max-nested-callbacks */ -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$, combineLatest} from 'rxjs'; import {switchMap, map} from 'rxjs/operators'; diff --git a/app/components/team_sidebar/team_list/team_item/index.ts b/app/components/team_sidebar/team_list/team_item/index.ts index f639f7bd4..26efcf01e 100644 --- a/app/components/team_sidebar/team_list/team_item/index.ts +++ b/app/components/team_sidebar/team_list/team_item/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {combineLatestWith, map, switchMap, distinctUntilChanged} from 'rxjs/operators'; diff --git a/app/components/threads_button/index.ts b/app/components/threads_button/index.ts index eec587956..d63e44ddb 100644 --- a/app/components/threads_button/index.ts +++ b/app/components/threads_button/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/components/user_avatars_stack/index.tsx b/app/components/user_avatars_stack/index.tsx index 7b7c47aba..f71d403f9 100644 --- a/app/components/user_avatars_stack/index.tsx +++ b/app/components/user_avatars_stack/index.tsx @@ -19,7 +19,6 @@ import {typography} from '@utils/typography'; import UserAvatar from './user_avatar'; import UsersList from './users_list'; -import type {BottomSheetProps} from '@gorhom/bottom-sheet'; import type UserModel from '@typings/database/models/servers/user'; const OVERFLOW_DISPLAY_LIMIT = 99; @@ -125,7 +124,7 @@ const UserAvatarsStack = ({ ); - const snapPoints: BottomSheetProps['snapPoints'] = [1, bottomSheetSnapPoint(Math.min(users.length, 5), USER_ROW_HEIGHT, bottom) + TITLE_HEIGHT]; + const snapPoints: Array = [1, bottomSheetSnapPoint(Math.min(users.length, 5), USER_ROW_HEIGHT, bottom) + TITLE_HEIGHT]; if (users.length > 5) { snapPoints.push('80%'); } diff --git a/app/components/user_avatars_stack/user_avatar/index.ts b/app/components/user_avatars_stack/user_avatar/index.ts index 84c1b422e..4fa98ab81 100644 --- a/app/components/user_avatars_stack/user_avatar/index.ts +++ b/app/components/user_avatars_stack/user_avatar/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import UserAvatar from './user_avatar'; diff --git a/app/components/user_item/index.ts b/app/components/user_item/index.ts index a21272660..33485e4b6 100644 --- a/app/components/user_item/index.ts +++ b/app/components/user_item/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {distinctUntilChanged, switchMap} from 'rxjs/operators'; diff --git a/app/components/user_list/__snapshots__/index.test.tsx.snap b/app/components/user_list/__snapshots__/index.test.tsx.snap index dfd9f626f..80e960a70 100644 --- a/app/components/user_list/__snapshots__/index.test.tsx.snap +++ b/app/components/user_list/__snapshots__/index.test.tsx.snap @@ -30,6 +30,7 @@ exports[`components/channel_list_row should show no results 1`] = ` "desktop_sound": "true", "email": "true", "first_name": "true", + "highlight_keys": "", "mention_keys": "", "push": "mention", "push_status": "away", @@ -317,6 +318,7 @@ exports[`components/channel_list_row should show results and tutorial 1`] = ` "desktop_sound": "true", "email": "true", "first_name": "true", + "highlight_keys": "", "mention_keys": "", "push": "mention", "push_status": "away", @@ -613,6 +615,7 @@ exports[`components/channel_list_row should show results no tutorial 1`] = ` "desktop_sound": "true", "email": "true", "first_name": "true", + "highlight_keys": "", "mention_keys": "", "push": "mention", "push_status": "away", @@ -900,6 +903,7 @@ exports[`components/channel_list_row should show results no tutorial 2 users 1`] "desktop_sound": "true", "email": "true", "first_name": "true", + "highlight_keys": "", "mention_keys": "", "push": "mention", "push_status": "away", @@ -932,6 +936,7 @@ exports[`components/channel_list_row should show results no tutorial 2 users 1`] "desktop_sound": "true", "email": "true", "first_name": "true", + "highlight_keys": "", "mention_keys": "", "push": "mention", "push_status": "away", diff --git a/app/components/user_list/index.test.tsx b/app/components/user_list/index.test.tsx index 8ae4fbaf6..72ef80f53 100644 --- a/app/components/user_list/index.test.tsx +++ b/app/components/user_list/index.test.tsx @@ -34,6 +34,7 @@ describe('components/channel_list_row', () => { email: 'true', first_name: 'true', mention_keys: '', + highlight_keys: '', push: 'mention', push_status: 'away', }, @@ -61,6 +62,7 @@ describe('components/channel_list_row', () => { email: 'true', first_name: 'true', mention_keys: '', + highlight_keys: '', push: 'mention', push_status: 'away', }, diff --git a/app/constants/calls.ts b/app/constants/calls.ts index e9bc049fa..46003e170 100644 --- a/app/constants/calls.ts +++ b/app/constants/calls.ts @@ -12,6 +12,13 @@ const RequiredServer = { PATCH_VERSION: 0, }; +const MultiSessionCallsVersion = { + FULL_VERSION: '0.21.0', + MAJOR_VERSION: 0, + MIN_VERSION: 21, + PATCH_VERSION: 0, +}; + const PluginId = 'com.mattermost.calls'; const REACTION_TIMEOUT = 10000; @@ -26,6 +33,7 @@ export enum MessageBarType { export default { RefreshConfigMillis, RequiredServer, + MultiSessionCallsVersion, PluginId, REACTION_TIMEOUT, REACTION_LIMIT, diff --git a/app/constants/license.ts b/app/constants/license.ts index 7016c3d57..36edb975e 100644 --- a/app/constants/license.ts +++ b/app/constants/license.ts @@ -9,4 +9,9 @@ export default { Professional: 'professional', Enterprise: 'enterprise', }, + SelfHostedProducts: { + STARTER: 'starter', + PROFESSIONAL: 'professional', + ENTERPRISE: 'enterprise', + }, }; diff --git a/app/constants/push_notification.ts b/app/constants/push_notification.ts index fc918c066..37ed85d3b 100644 --- a/app/constants/push_notification.ts +++ b/app/constants/push_notification.ts @@ -11,6 +11,10 @@ export const NOTIFICATION_TYPE = { SESSION: 'session', }; +export const NOTIFICATION_SUB_TYPE = { + CALLS: 'calls', +}; + export default { CATEGORY, NOTIFICATION_TYPE, diff --git a/app/constants/screens.ts b/app/constants/screens.ts index 358bdf236..2c0014171 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -13,6 +13,7 @@ export const CHANNEL_FILES = 'ChannelFiles'; export const CHANNEL_INFO = 'ChannelInfo'; export const CHANNEL_NOTIFICATION_PREFERENCES = 'ChannelNotificationPreferences'; export const CODE = 'Code'; +export const CONVERT_GM_TO_CHANNEL = 'ConvertGMToChannel'; export const CREATE_DIRECT_MESSAGE = 'CreateDirectMessage'; export const CREATE_OR_EDIT_CHANNEL = 'CreateOrEditChannel'; export const CREATE_TEAM = 'CreateTeam'; @@ -65,6 +66,7 @@ export const SHARE_FEEDBACK = 'ShareFeedback'; export const SNACK_BAR = 'SnackBar'; export const SSO = 'SSO'; export const TABLE = 'Table'; +export const TEAM_SELECTOR_LIST = 'TeamSelectorList'; export const TERMS_OF_SERVICE = 'TermsOfService'; export const THREAD = 'Thread'; export const THREAD_FOLLOW_BUTTON = 'ThreadFollowButton'; @@ -84,6 +86,7 @@ export default { CHANNEL_INFO, CHANNEL_NOTIFICATION_PREFERENCES, CODE, + CONVERT_GM_TO_CHANNEL, CREATE_DIRECT_MESSAGE, CREATE_OR_EDIT_CHANNEL, CREATE_TEAM, @@ -136,6 +139,7 @@ export default { SNACK_BAR, SSO, TABLE, + TEAM_SELECTOR_LIST, TERMS_OF_SERVICE, THREAD, THREAD_FOLLOW_BUTTON, diff --git a/app/constants/server_errors.ts b/app/constants/server_errors.ts index 39810710d..5ca22c999 100644 --- a/app/constants/server_errors.ts +++ b/app/constants/server_errors.ts @@ -7,4 +7,5 @@ export default { PLUGIN_DISMISSED_POST_ERROR: 'plugin.message_will_be_posted.dismiss_post', SEND_EMAIL_WITH_DEFAULTS_ERROR: 'api.team.invite_members.unable_to_send_email_with_defaults.app_error', TEAM_MEMBERSHIP_DENIAL_ERROR_ID: 'api.team.add_members.user_denied', + DUPLICATE_CHANNEL_NAME: 'store.sql_channel.save_channel.exists.app_error', }; diff --git a/app/constants/websocket.ts b/app/constants/websocket.ts index 454551067..51c745284 100644 --- a/app/constants/websocket.ts +++ b/app/constants/websocket.ts @@ -61,8 +61,15 @@ const WebsocketEvents = { APPS_FRAMEWORK_REFRESH_BINDINGS: 'custom_com.mattermost.apps_refresh_bindings', CALLS_CHANNEL_ENABLED: `custom_${Calls.PluginId}_channel_enable_voice`, CALLS_CHANNEL_DISABLED: `custom_${Calls.PluginId}_channel_disable_voice`, + + // DEPRECATED in favour of user_joined (since v0.21.0) CALLS_USER_CONNECTED: `custom_${Calls.PluginId}_user_connected`, + + // DEPRECATED in favour of user_left (since v0.21.0) CALLS_USER_DISCONNECTED: `custom_${Calls.PluginId}_user_disconnected`, + + CALLS_USER_JOINED: `custom_${Calls.PluginId}_user_joined`, + CALLS_USER_LEFT: `custom_${Calls.PluginId}_user_left`, CALLS_USER_MUTED: `custom_${Calls.PluginId}_user_muted`, CALLS_USER_UNMUTED: `custom_${Calls.PluginId}_user_unmuted`, CALLS_USER_VOICE_ON: `custom_${Calls.PluginId}_user_voice_on`, diff --git a/app/context/theme/index.tsx b/app/context/theme/index.tsx index 6efe14b0f..1567d3e6a 100644 --- a/app/context/theme/index.tsx +++ b/app/context/theme/index.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import withObservables from '@nozbe/with-observables'; +import {withObservables} from '@nozbe/watermelondb/react'; import React, {type ComponentType, createContext, useEffect, useState} from 'react'; import {Appearance} from 'react-native'; diff --git a/app/context/user_locale/index.tsx b/app/context/user_locale/index.tsx index 0262d3969..c1b587701 100644 --- a/app/context/user_locale/index.tsx +++ b/app/context/user_locale/index.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import withObservables from '@nozbe/with-observables'; +import {withObservables} from '@nozbe/watermelondb/react'; import React, {type ComponentType, createContext} from 'react'; import {IntlProvider} from 'react-intl'; import {of as of$} from 'rxjs'; diff --git a/app/database/components/index.tsx b/app/database/components/index.tsx index 68296eda5..39dba96bb 100644 --- a/app/database/components/index.tsx +++ b/app/database/components/index.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import DatabaseProvider from '@nozbe/watermelondb/DatabaseProvider'; +import {DatabaseProvider} from '@nozbe/watermelondb/react'; import React, {type ComponentType, useEffect, useState} from 'react'; import ServerProvider from '@context/server'; diff --git a/app/database/models/server/user.ts b/app/database/models/server/user.ts index dd4a5db32..c9c22154e 100644 --- a/app/database/models/server/user.ts +++ b/app/database/models/server/user.ts @@ -16,7 +16,7 @@ import type ReactionModel from '@typings/database/models/servers/reaction'; import type TeamMembershipModel from '@typings/database/models/servers/team_membership'; import type ThreadParticipantsModel from '@typings/database/models/servers/thread_participant'; import type UserModelInterface from '@typings/database/models/servers/user'; -import type {UserMentionKey} from '@typings/global/markdown'; +import type {UserMentionKey, HighlightWithoutNotificationKey} from '@typings/global/markdown'; const { CHANNEL, @@ -194,4 +194,24 @@ export default class UserModel extends Model implements UserModelInterface { m.key !== '@here' )); } + + get highlightKeys() { + if (!this.notifyProps) { + return []; + } + + const highlightWithoutNotificationKeys: HighlightWithoutNotificationKey[] = []; + + if (this.notifyProps?.highlight_keys?.length) { + this.notifyProps.highlight_keys. + split(','). + forEach((key) => { + if (key.trim().length > 0) { + highlightWithoutNotificationKeys.push({key: key.trim()}); + } + }); + } + + return highlightWithoutNotificationKeys; + } } diff --git a/app/database/operator/server_data_operator/handlers/user.test.ts b/app/database/operator/server_data_operator/handlers/user.test.ts index b4a230db3..2fd508b15 100644 --- a/app/database/operator/server_data_operator/handlers/user.test.ts +++ b/app/database/operator/server_data_operator/handlers/user.test.ts @@ -74,6 +74,7 @@ describe('*** Operator: User Handlers tests ***', () => { first_name: 'true', mark_unread: 'mention', mention_keys: '', + highlight_keys: '', push: 'mention', channel: 'true', auto_responder_active: 'false', diff --git a/app/database/operator/server_data_operator/transformers/user.test.ts b/app/database/operator/server_data_operator/transformers/user.test.ts index 05d886995..be4b088d7 100644 --- a/app/database/operator/server_data_operator/transformers/user.test.ts +++ b/app/database/operator/server_data_operator/transformers/user.test.ts @@ -57,6 +57,7 @@ describe('*** USER Prepare Records Test ***', () => { email: 'true', first_name: 'true', mention_keys: '', + highlight_keys: '', mark_unread: 'mention', push: 'mention', channel: 'true', diff --git a/app/hooks/markdown.ts b/app/hooks/markdown.ts new file mode 100644 index 000000000..7ea287808 --- /dev/null +++ b/app/hooks/markdown.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {useMemo} from 'react'; + +import {getUsersByUsername} from '@utils/user'; + +import type GroupModel from '@typings/database/models/servers/group'; +import type UserModel from '@typings/database/models/servers/user'; + +export function useMemoMentionedUser(users: UserModel[], mentionName: string) { + return useMemo(() => { + const usersByUsername = getUsersByUsername(users); + let mn = mentionName.toLowerCase(); + + while (mn.length > 0) { + if (usersByUsername[mn]) { + return usersByUsername[mn]; + } + + // Repeatedly trim off trailing punctuation in case this is at the end of a sentence + if ((/[._-]$/).test(mn)) { + mn = mn.substring(0, mn.length - 1); + } else { + break; + } + } + + return undefined; + }, [users, mentionName]); +} + +export function useMemoMentionedGroup(groups: GroupModel[], user: UserModel | undefined, mentionName: string) { + // Checks if the mention is a group + return useMemo(() => { + if (user?.username) { + return undefined; + } + const getGroupsByName = (gs: GroupModel[]) => { + const groupsByName: Dictionary = {}; + + for (const g of gs) { + groupsByName[g.name] = g; + } + + return groupsByName; + }; + + const groupsByName = getGroupsByName(groups); + let mn = mentionName.toLowerCase(); + + while (mn.length > 0) { + if (groupsByName[mn]) { + return groupsByName[mn]; + } + + // Repeatedly trim off trailing punctuation in case this is at the end of a sentence + if ((/[._-]$/).test(mn)) { + mn = mn.substring(0, mn.length - 1); + } else { + break; + } + } + + return undefined; + }, [groups, user, mentionName]); +} diff --git a/app/i18n/index.ts b/app/i18n/index.ts index 57d4adc5f..875b88968 100644 --- a/app/i18n/index.ts +++ b/app/i18n/index.ts @@ -23,6 +23,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/bg'); require('@formatjs/intl-numberformat/locale-data/bg'); require('@formatjs/intl-datetimeformat/locale-data/bg'); + require('@formatjs/intl-listformat/locale-data/bg'); translations = require('@assets/i18n/bg.json'); break; @@ -30,6 +31,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/de'); require('@formatjs/intl-numberformat/locale-data/de'); require('@formatjs/intl-datetimeformat/locale-data/de'); + require('@formatjs/intl-listformat/locale-data/de'); translations = require('@assets/i18n/de.json'); break; @@ -37,6 +39,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/en'); require('@formatjs/intl-numberformat/locale-data/en'); require('@formatjs/intl-datetimeformat/locale-data/en'); + require('@formatjs/intl-listformat/locale-data/en'); translations = require('@assets/i18n/en_AU.json'); break; @@ -44,6 +47,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/es'); require('@formatjs/intl-numberformat/locale-data/es'); require('@formatjs/intl-datetimeformat/locale-data/es'); + require('@formatjs/intl-listformat/locale-data/es'); translations = require('@assets/i18n/es.json'); break; @@ -51,6 +55,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/fa'); require('@formatjs/intl-numberformat/locale-data/fa'); require('@formatjs/intl-datetimeformat/locale-data/fa'); + require('@formatjs/intl-listformat/locale-data/fa'); translations = require('@assets/i18n/fa.json'); break; @@ -58,6 +63,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/fr'); require('@formatjs/intl-numberformat/locale-data/fr'); require('@formatjs/intl-datetimeformat/locale-data/fr'); + require('@formatjs/intl-listformat/locale-data/fr'); translations = require('@assets/i18n/fr.json'); break; @@ -65,6 +71,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/hu'); require('@formatjs/intl-numberformat/locale-data/hu'); require('@formatjs/intl-datetimeformat/locale-data/hu'); + require('@formatjs/intl-listformat/locale-data/hu'); translations = require('@assets/i18n/hu.json'); break; @@ -72,6 +79,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/it'); require('@formatjs/intl-numberformat/locale-data/it'); require('@formatjs/intl-datetimeformat/locale-data/it'); + require('@formatjs/intl-listformat/locale-data/it'); translations = require('@assets/i18n/it.json'); break; @@ -79,6 +87,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/ja'); require('@formatjs/intl-numberformat/locale-data/ja'); require('@formatjs/intl-datetimeformat/locale-data/ja'); + require('@formatjs/intl-listformat/locale-data/ja'); translations = require('@assets/i18n/ja.json'); break; @@ -86,6 +95,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/ko'); require('@formatjs/intl-numberformat/locale-data/ko'); require('@formatjs/intl-datetimeformat/locale-data/ko'); + require('@formatjs/intl-listformat/locale-data/ko'); translations = require('@assets/i18n/ko.json'); break; @@ -93,6 +103,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/nl'); require('@formatjs/intl-numberformat/locale-data/nl'); require('@formatjs/intl-datetimeformat/locale-data/nl'); + require('@formatjs/intl-listformat/locale-data/nl'); translations = require('@assets/i18n/nl.json'); break; @@ -100,6 +111,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/pl'); require('@formatjs/intl-numberformat/locale-data/pl'); require('@formatjs/intl-datetimeformat/locale-data/pl'); + require('@formatjs/intl-listformat/locale-data/pl'); translations = require('@assets/i18n/pl.json'); break; @@ -107,6 +119,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/pt'); require('@formatjs/intl-numberformat/locale-data/pt'); require('@formatjs/intl-datetimeformat/locale-data/pt'); + require('@formatjs/intl-listformat/locale-data/pt'); translations = require('@assets/i18n/pt-BR.json'); break; @@ -114,6 +127,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/ro'); require('@formatjs/intl-numberformat/locale-data/ro'); require('@formatjs/intl-datetimeformat/locale-data/ro'); + require('@formatjs/intl-listformat/locale-data/ro'); translations = require('@assets/i18n/ro.json'); break; @@ -121,6 +135,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/ru'); require('@formatjs/intl-numberformat/locale-data/ru'); require('@formatjs/intl-datetimeformat/locale-data/ru'); + require('@formatjs/intl-listformat/locale-data/ru'); translations = require('@assets/i18n/ru.json'); break; @@ -128,6 +143,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/sv'); require('@formatjs/intl-numberformat/locale-data/sv'); require('@formatjs/intl-datetimeformat/locale-data/sv'); + require('@formatjs/intl-listformat/locale-data/sv'); translations = require('@assets/i18n/sv.json'); break; @@ -135,6 +151,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/tr'); require('@formatjs/intl-numberformat/locale-data/tr'); require('@formatjs/intl-datetimeformat/locale-data/tr'); + require('@formatjs/intl-listformat/locale-data/tr'); translations = require('@assets/i18n/tr.json'); break; @@ -142,6 +159,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/uk'); require('@formatjs/intl-numberformat/locale-data/uk'); require('@formatjs/intl-datetimeformat/locale-data/uk'); + require('@formatjs/intl-listformat/locale-data/uk'); translations = require('@assets/i18n/uk.json'); break; @@ -149,6 +167,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/vi'); require('@formatjs/intl-numberformat/locale-data/vi'); require('@formatjs/intl-datetimeformat/locale-data/vi'); + require('@formatjs/intl-listformat/locale-data/vi'); translations = require('@assets/i18n/uk.json'); break; @@ -164,6 +183,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/en'); require('@formatjs/intl-numberformat/locale-data/en'); require('@formatjs/intl-datetimeformat/locale-data/en'); + require('@formatjs/intl-listformat/locale-data/en'); translations = en; break; @@ -180,6 +200,7 @@ function loadChinesePolyfills() { require('@formatjs/intl-pluralrules/locale-data/zh'); require('@formatjs/intl-numberformat/locale-data/zh'); require('@formatjs/intl-datetimeformat/locale-data/zh'); + require('@formatjs/intl-listformat/locale-data/zh'); } export function getLocaleFromLanguage(lang: string) { diff --git a/app/init/push_notifications.ts b/app/init/push_notifications.ts index 132332da1..f713d4f47 100644 --- a/app/init/push_notifications.ts +++ b/app/init/push_notifications.ts @@ -18,6 +18,7 @@ import {storeDeviceToken} from '@actions/app/global'; import {markChannelAsViewed} from '@actions/local/channel'; import {updateThread} from '@actions/local/thread'; import {backgroundNotification, openNotification} from '@actions/remote/notifications'; +import {isCallsStartedMessage} from '@calls/utils'; import {Device, Events, Navigation, PushNotification, Screens} from '@constants'; import DatabaseManager from '@database/manager'; import {DEFAULT_LOCALE, getLocalizedMessage, t} from '@i18n'; @@ -107,6 +108,11 @@ class PushNotifications { handleInAppNotification = async (serverUrl: string, notification: NotificationWithData) => { const {payload} = notification; + // Do not show overlay if this is a call-started message (the call_notification will alert the user) + if (isCallsStartedMessage(payload)) { + return; + } + const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (database) { const isTabletDevice = await isTablet(); diff --git a/app/managers/session_manager.ts b/app/managers/session_manager.ts index 1ba1fbbb0..4457d03b8 100644 --- a/app/managers/session_manager.ts +++ b/app/managers/session_manager.ts @@ -35,7 +35,7 @@ type LogoutCallbackArg = { class SessionManager { private previousAppState: AppStateStatus; private scheduling = false; - private terminatingSessionUrl: undefined|string; + private terminatingSessionUrl = new Set(); constructor() { if (Platform.OS === 'android') { @@ -162,9 +162,11 @@ class SessionManager { }; private onLogout = async ({serverUrl, removeServer}: LogoutCallbackArg) => { - if (this.terminatingSessionUrl === serverUrl) { + if (this.terminatingSessionUrl.has(serverUrl)) { return; } + this.terminatingSessionUrl.add(serverUrl); + const activeServerUrl = await DatabaseManager.getActiveServerUrl(); const activeServerDisplayName = await DatabaseManager.getActiveServerDisplayName(); @@ -190,10 +192,11 @@ class SessionManager { relaunchApp({launchType, serverUrl, displayName}); } + this.terminatingSessionUrl.delete(serverUrl); }; private onSessionExpired = async (serverUrl: string) => { - this.terminatingSessionUrl = serverUrl; + this.terminatingSessionUrl.add(serverUrl); await logout(serverUrl, false, false, true); await this.terminateSession(serverUrl, false); @@ -206,7 +209,7 @@ class SessionManager { } else { EphemeralStore.theme = undefined; } - this.terminatingSessionUrl = undefined; + this.terminatingSessionUrl.delete(serverUrl); }; } diff --git a/app/managers/websocket_manager.ts b/app/managers/websocket_manager.ts index ca8e0bc3e..f499468f6 100644 --- a/app/managers/websocket_manager.ts +++ b/app/managers/websocket_manager.ts @@ -31,7 +31,7 @@ class WebsocketManager { private isBackgroundTimerRunning = false; private netConnected = false; private previousActiveState: boolean; - private statusUpdatesIntervalIDs: Record = {}; + private statusUpdatesIntervalIDs: Record = {}; private backgroundIntervalId: number | undefined; private firstConnectionSynced: Record = {}; diff --git a/app/products/calls/actions/calls.test.ts b/app/products/calls/actions/calls.test.ts index 15625e64a..1b2e35dba 100644 --- a/app/products/calls/actions/calls.test.ts +++ b/app/products/calls/actions/calls.test.ts @@ -39,10 +39,9 @@ const mockClient = { getCalls: jest.fn(() => [ { call: { - users: ['user-1', 'user-2'], - states: { - 'user-1': {unmuted: true}, - 'user-2': {unmuted: false}, + sessions: { + session1: {session_id: 'session1', user_id: 'user-1', unmuted: true}, + session2: {session_id: 'session1', user_id: 'user-1', unmuted: false}, }, start_at: 123, screen_sharing_id: '', @@ -58,6 +57,7 @@ const mockClient = { DefaultEnabled: true, last_retrieved_at: 1234, })), + getVersion: jest.fn(() => ({})), getPluginsManifests: jest.fn(() => ( [ {id: 'playbooks'}, @@ -101,13 +101,13 @@ jest.mock('react-native-navigation', () => ({ const addFakeCall = (serverUrl: string, channelId: string) => { const call: Call = { id: 'call', - participants: { - xohi8cki9787fgiryne716u84o: {id: 'xohi8cki9787fgiryne716u84o', muted: false, raisedHand: 0}, - xohi8cki9787fgiryne716u841: {id: 'xohi8cki9787fgiryne716u84o', muted: true, raisedHand: 0}, - xohi8cki9787fgiryne716u842: {id: 'xohi8cki9787fgiryne716u84o', muted: false, raisedHand: 0}, - xohi8cki9787fgiryne716u843: {id: 'xohi8cki9787fgiryne716u84o', muted: true, raisedHand: 0}, - xohi8cki9787fgiryne716u844: {id: 'xohi8cki9787fgiryne716u84o', muted: false, raisedHand: 0}, - xohi8cki9787fgiryne716u845: {id: 'xohi8cki9787fgiryne716u84o', muted: true, raisedHand: 0}, + sessions: { + a23456abcdefghijklmnopqrs: {sessionId: 'a23456abcdefghijklmnopqrs', userId: 'xohi8cki9787fgiryne716u84o', muted: false, raisedHand: 0}, + a12345667890bcdefghijklmn1: {sessionId: 'a12345667890bcdefghijklmn1', userId: 'xohi8cki9787fgiryne716u84o', muted: true, raisedHand: 0}, + a12345667890bcdefghijklmn2: {sessionId: 'a12345667890bcdefghijklmn2', userId: 'xohi8cki9787fgiryne716u84o', muted: false, raisedHand: 0}, + a12345667890bcdefghijklmn3: {sessionId: 'a12345667890bcdefghijklmn3', userId: 'xohi8cki9787fgiryne716u84o', muted: true, raisedHand: 0}, + a12345667890bcdefghijklmn4: {sessionId: 'a12345667890bcdefghijklmn4', userId: 'xohi8cki9787fgiryne716u84o', muted: false, raisedHand: 0}, + a12345667890bcdefghijklmn5: {sessionId: 'a12345667890bcdefghijklmn5', userId: 'xohi8cki9787fgiryne716u84o', muted: true, raisedHand: 0}, }, channelId, startTime: (new Date()).getTime(), @@ -205,7 +205,7 @@ describe('Actions.Calls', () => { // manually call newCurrentConnection because newConnection is mocked newCurrentCall('server1', 'channel-id', 'myUserId'); - userJoinedCall('server1', 'channel-id', 'myUserId'); + userJoinedCall('server1', 'channel-id', 'myUserId', 'mySessionId'); }); assert.equal(response!.data, 'channel-id'); assert.equal((result.current[1] as CurrentCall | null)?.channelId, 'channel-id'); @@ -239,7 +239,7 @@ describe('Actions.Calls', () => { // manually call newCurrentConnection because newConnection is mocked newCurrentCall('server1', 'channel-id', 'myUserId'); - userJoinedCall('server1', 'channel-id', 'myUserId'); + userJoinedCall('server1', 'channel-id', 'myUserId', 'mySessionId'); }); assert.equal(response!.data, 'channel-id'); assert.equal((result.current[1] as CurrentCall | null)?.channelId, 'channel-id'); @@ -269,7 +269,7 @@ describe('Actions.Calls', () => { // manually call newCurrentConnection because newConnection is mocked newCurrentCall('server1', 'channel-id', 'myUserId'); - userJoinedCall('server1', 'channel-id', 'myUserId'); + userJoinedCall('server1', 'channel-id', 'myUserId', 'mySessionId'); }); assert.equal(response!.data, 'channel-id'); assert.equal((result.current[1] as CurrentCall | null)?.channelId, 'channel-id'); diff --git a/app/products/calls/actions/calls.ts b/app/products/calls/actions/calls.ts index 5d80e32f5..e9c2a3e23 100644 --- a/app/products/calls/actions/calls.ts +++ b/app/products/calls/actions/calls.ts @@ -39,8 +39,8 @@ import {displayUsername, getUserIdFromChannelName, isSystemAdmin} from '@utils/u import {newConnection} from '../connection/connection'; -import type {AudioDevice, Call, CallParticipant, CallsConnection} from '@calls/types/calls'; -import type {CallChannelState, CallState, EmojiData} from '@mattermost/calls/lib/types'; +import type {AudioDevice, Call, CallSession, CallsConnection} from '@calls/types/calls'; +import type {CallChannelState, CallState, EmojiData, SessionState} from '@mattermost/calls/lib/types'; import type {IntlShape} from 'react-intl'; let connection: CallsConnection | null = null; @@ -59,8 +59,8 @@ export const loadConfig = async (serverUrl: string, force = false) => { try { const client = NetworkManager.getClient(serverUrl); - const data = await client.getCallsConfig(); - const nextConfig = {...data, last_retrieved_at: now}; + const configs = await Promise.all([client.getCallsConfig(), client.getVersion()]); + const nextConfig = {...configs[0], version: configs[1], last_retrieved_at: now}; setConfig(serverUrl, nextConfig); return {data: nextConfig}; } catch (error) { @@ -87,7 +87,7 @@ export const loadCalls = async (serverUrl: string, userId: string) => { for (const channel of resp) { if (channel.call) { - callsResults[channel.channel_id] = createCallAndAddToIds(channel.channel_id, channel.call, ids); + callsResults[channel.channel_id] = createCallAndAddToIds(channel.channel_id, convertOldCallToNew(channel.call), ids); } if (typeof channel.enabled !== 'undefined') { @@ -119,7 +119,7 @@ export const loadCallForChannel = async (serverUrl: string, channelId: string) = let call: Call | undefined; const ids = new Set(); if (resp.call) { - call = createCallAndAddToIds(channelId, resp.call, ids); + call = createCallAndAddToIds(channelId, convertOldCallToNew(resp.call), ids); } // Batch load user models async because we'll need them later @@ -132,22 +132,48 @@ export const loadCallForChannel = async (serverUrl: string, channelId: string) = return {data: {call, enabled: resp.enabled}}; }; +// Converts pre-0.21.0 call to 0.21.0+ call. Can be removed when we stop supporting pre-0.21.0 +// Also can be removed: all code prefaced with a "Pre v0.21.0, sessionID == userID" comment +// Does nothing if the call is in the new format. +const convertOldCallToNew = (call: CallState): CallState => { + if (call.sessions) { + return call; + } + + return { + ...call, + sessions: call.users.reduce((accum, cur, curIdx) => { + accum.push({ + session_id: cur, + user_id: cur, + unmuted: call.states && call.states[curIdx] ? call.states[curIdx].unmuted : false, + raised_hand: call.states && call.states[curIdx] ? call.states[curIdx].raised_hand : 0, + }); + return accum; + }, [] as SessionState[]), + screen_sharing_session_id: call.screen_sharing_id, + }; +}; + const createCallAndAddToIds = (channelId: string, call: CallState, ids: Set) => { return { - participants: call.users.reduce((accum, cur, curIdx) => { + sessions: Object.values(call.sessions).reduce((accum, cur) => { // Add the id to the set of UserModels we want to ensure are loaded. - ids.add(cur); + ids.add(cur.user_id); // Create the CallParticipant - const muted = call.states && call.states[curIdx] ? !call.states[curIdx].unmuted : true; - const raisedHand = call.states && call.states[curIdx] ? call.states[curIdx].raised_hand : 0; - accum[cur] = {id: cur, muted, raisedHand}; + accum[cur.session_id] = { + userId: cur.user_id, + sessionId: cur.session_id, + raisedHand: cur.raised_hand || 0, + muted: !cur.unmuted, + }; return accum; - }, {} as Dictionary), + }, {} as Dictionary), channelId, id: call.id, startTime: call.start_at, - screenOn: call.screen_sharing_id, + screenOn: call.screen_sharing_session_id, threadId: call.thread_id, ownerId: call.owner_id, hostId: call.host_id, @@ -351,12 +377,12 @@ export const getEndCallMessage = async (serverUrl: string, channelId: string, cu return msg; } - const numParticipants = Object.keys(call.participants).length; + const numSessions = Object.keys(call.sessions).length; msg = intl.formatMessage({ id: 'mobile.calls_end_msg_channel', - defaultMessage: 'Are you sure you want to end a call with {numParticipants} participants in {displayName}?', - }, {numParticipants, displayName: channel.displayName}); + defaultMessage: 'Are you sure you want to end a call with {numSessions} participants in {displayName}?', + }, {numSessions, displayName: channel.displayName}); if (channel.type === General.DM_CHANNEL) { const otherID = getUserIdFromChannelName(currentUserId, channel.name); diff --git a/app/products/calls/client/rest.ts b/app/products/calls/client/rest.ts index 8e88e4e0b..423ad7388 100644 --- a/app/products/calls/client/rest.ts +++ b/app/products/calls/client/rest.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import type {ApiResp} from '@calls/types/calls'; +import type {ApiResp, CallsVersion} from '@calls/types/calls'; import type {CallChannelState, CallRecordingState, CallsConfig} from '@mattermost/calls/lib/types'; import type {RTCIceServer} from 'react-native-webrtc'; @@ -10,6 +10,7 @@ export interface ClientCallsMix { getCalls: () => Promise; getCallForChannel: (channelId: string) => Promise; getCallsConfig: () => Promise; + getVersion: () => Promise; enableChannelCalls: (channelId: string, enable: boolean) => Promise; endCall: (channelId: string) => Promise; genTURNCredentials: () => Promise; @@ -52,6 +53,17 @@ const ClientCalls = (superclass: any) => class extends superclass { ) as CallsConfig; }; + getVersion = async () => { + try { + return this.doFetch( + `${this.getCallsRoute()}/version`, + {method: 'get'}, + ); + } catch (e) { + return {}; + } + }; + enableChannelCalls = async (channelId: string, enable: boolean) => { return this.doFetch( `${this.getCallsRoute()}/${channelId}`, diff --git a/app/products/calls/components/call_notification/index.ts b/app/products/calls/components/call_notification/index.ts index ff0dfc6f2..e7eb0838a 100644 --- a/app/products/calls/components/call_notification/index.ts +++ b/app/products/calls/components/call_notification/index.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import withObservables from '@nozbe/with-observables'; +import {withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {distinctUntilChanged, switchMap} from 'rxjs/operators'; diff --git a/app/products/calls/components/calls_custom_message/index.ts b/app/products/calls/components/calls_custom_message/index.ts index 88c2d850f..2547bf33f 100644 --- a/app/products/calls/components/calls_custom_message/index.ts +++ b/app/products/calls/components/calls_custom_message/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {distinctUntilChanged, switchMap} from 'rxjs/operators'; diff --git a/app/products/calls/components/channel_info_start/index.ts b/app/products/calls/components/channel_info_start/index.ts index 06bf3b22c..4a965ebe6 100644 --- a/app/products/calls/components/channel_info_start/index.ts +++ b/app/products/calls/components/channel_info_start/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {distinctUntilChanged, switchMap} from 'rxjs/operators'; diff --git a/app/products/calls/components/current_call_bar/current_call_bar.tsx b/app/products/calls/components/current_call_bar/current_call_bar.tsx index 2b705b605..b4e1d3c51 100644 --- a/app/products/calls/components/current_call_bar/current_call_bar.tsx +++ b/app/products/calls/components/current_call_bar/current_call_bar.tsx @@ -23,14 +23,13 @@ import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; import {displayUsername} from '@utils/user'; -import type {CallsTheme, CurrentCall} from '@calls/types/calls'; -import type UserModel from '@typings/database/models/servers/user'; +import type {CallSession, CallsTheme, CurrentCall} from '@calls/types/calls'; import type {Options} from 'react-native-navigation'; type Props = { displayName: string; currentCall: CurrentCall | null; - userModelsDict: Dictionary; + sessionsDict: Dictionary; teammateNameDisplay: string; micPermissionsGranted: boolean; threadScreen?: boolean; @@ -135,7 +134,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: CallsTheme) => { const CurrentCallBar = ({ displayName, currentCall, - userModelsDict, + sessionsDict, teammateNameDisplay, micPermissionsGranted, threadScreen, @@ -169,7 +168,7 @@ const CurrentCallBar = ({ leaveCall(); }, []); - const myParticipant = currentCall?.participants[currentCall.myUserId]; + const mySession = currentCall?.sessions[currentCall.mySessionId]; // Since we can only see one user talking, it doesn't really matter who we show here (e.g., we can't // tell who is speaking louder). @@ -185,7 +184,7 @@ const CurrentCallBar = ({ if (speaker) { talkingMessage = ( - {displayUsername(userModelsDict[speaker], intl.locale, teammateNameDisplay)} + {displayUsername(sessionsDict[speaker].userModel, intl.locale, teammateNameDisplay)} {' '} { formatMessage({ @@ -197,7 +196,7 @@ const CurrentCallBar = ({ } const muteUnmute = () => { - if (myParticipant?.muted) { + if (mySession?.muted) { unmuteMyself(); } else { muteMyself(); @@ -208,7 +207,7 @@ const CurrentCallBar = ({ // The user should receive an alert if all of the following conditions apply: // - Recording has started and recording has not ended. - const isHost = Boolean(currentCall?.hostId === myParticipant?.id); + const isHost = Boolean(currentCall?.hostId === mySession?.userId); if (currentCall?.recState?.start_at && !currentCall?.recState?.end_at) { recordingAlert(isHost, intl); } @@ -233,7 +232,7 @@ const CurrentCallBar = ({ > { const currentCall = observeCurrentCall(); const ccServerUrl = currentCall.pipe( @@ -33,17 +31,9 @@ const enhanced = withObservables([], () => { switchMap((c) => of$(c?.displayName || '')), distinctUntilChanged(), ); - const participantIds = currentCall.pipe( - distinctUntilChanged((prev, curr) => prev?.participants === curr?.participants), // Did the participants object ref change? - switchMap((call) => (call ? of$(Object.keys(call.participants)) : of$([]))), - distinctUntilChanged((prev, curr) => idsAreEqual(prev, curr)), - ); - const userModelsDict = combineLatest([database, participantIds]).pipe( - switchMap(([db, ids]) => (db && ids.length > 0 ? queryUsersById(db, ids).observeWithColumns(['nickname', 'username', 'first_name', 'last_name']) : of$([]))), - switchMap((ps) => of$(arrayToDic(ps))), - ); const teammateNameDisplay = database.pipe( switchMap((db) => (db ? observeTeammateNameDisplay(db) : of$(''))), + distinctUntilChanged(), ); const micPermissionsGranted = observeGlobalCallsState().pipe( switchMap((gs) => of$(gs.micPermissionsGranted)), @@ -53,17 +43,10 @@ const enhanced = withObservables([], () => { return { displayName, currentCall, - userModelsDict, + sessionsDict: observeCurrentSessionsDict(), teammateNameDisplay, micPermissionsGranted, }; }); -function arrayToDic(participants: UserModel[]) { - return participants.reduce((accum, cur) => { - accum[cur.id] = cur; - return accum; - }, {} as Dictionary); -} - export default enhanced(CurrentCallBar); diff --git a/app/products/calls/components/floating_call_container.tsx b/app/products/calls/components/floating_call_container.tsx index 0a88911f8..d5c264efa 100644 --- a/app/products/calls/components/floating_call_container.tsx +++ b/app/products/calls/components/floating_call_container.tsx @@ -22,14 +22,22 @@ const style = StyleSheet.create({ }); type Props = { - channelId: string; - showJoinCallBanner: boolean; - showIncomingCalls: boolean; - isInACall: boolean; + channelId?: string; + showJoinCallBanner?: boolean; + showIncomingCalls?: boolean; + isInACall?: boolean; threadScreen?: boolean; + channelsScreen?: boolean; } -const FloatingCallContainer = ({channelId, showJoinCallBanner, showIncomingCalls, isInACall, threadScreen}: Props) => { +const FloatingCallContainer = ({ + channelId, + showJoinCallBanner, + showIncomingCalls, + isInACall, + threadScreen, + channelsScreen, +}: Props) => { const serverUrl = useServerUrl(); const insets = useSafeAreaInsets(); const isTablet = useIsTablet(); @@ -39,10 +47,13 @@ const FloatingCallContainer = ({channelId, showJoinCallBanner, showIncomingCalls const wrapperTop = { top: insets.top + topBarForTablet + topBarChannel, }; + const wrapperBottom = { + bottom: 8, + }; return ( - - {showJoinCallBanner && + + {showJoinCallBanner && channelId && of$(state.calls[channelId])), ); - const participants = callsState.pipe( - distinctUntilChanged((prev, curr) => prev?.participants === curr?.participants), // Did the participants object ref change? - switchMap((call) => (call ? of$(Object.keys(call.participants)) : of$([]))), - distinctUntilChanged((prev, curr) => idsAreEqual(prev, curr)), // Continue only if we have a different set of participant ids + const userModels = callsState.pipe( + distinctUntilChanged((prev, curr) => prev?.sessions === curr?.sessions), // Did the userModels object ref change? + switchMap((call) => (call ? of$(userIds(Object.values(call.sessions))) : of$([]))), + distinctUntilChanged((prev, curr) => idsAreEqual(prev, curr)), // Continue only if we have a different set of participant userIds switchMap((ids) => (ids.length > 0 ? queryUsersById(database, ids).observeWithColumns(['last_picture_update']) : of$([]))), ); const channelCallStartTime = callsState.pipe( @@ -47,7 +46,7 @@ const enhanced = withObservables(['serverUrl', 'channelId'], ({ return { callId, - participants, + userModels, channelCallStartTime, limitRestrictedInfo: observeIsCallLimitRestricted(database, serverUrl, channelId), }; diff --git a/app/products/calls/components/join_call_banner/join_call_banner.tsx b/app/products/calls/components/join_call_banner/join_call_banner.tsx index 7934c1a26..b31b78435 100644 --- a/app/products/calls/components/join_call_banner/join_call_banner.tsx +++ b/app/products/calls/components/join_call_banner/join_call_banner.tsx @@ -25,7 +25,7 @@ type Props = { channelId: string; callId: string; serverUrl: string; - participants: UserModel[]; + userModels: UserModel[]; channelCallStartTime: number; limitRestrictedInfo: LimitRestrictedInfo; } @@ -134,7 +134,7 @@ const JoinCallBanner = ({ channelId, callId, serverUrl, - participants, + userModels, channelCallStartTime, limitRestrictedInfo, }: Props) => { @@ -191,7 +191,7 @@ const JoinCallBanner = ({ { + ws.on('error', (err: Error) => { logDebug('calls: ws error', err); if (err === wsReconnectionTimeoutErr) { disconnect(); } }); - ws.on('close', () => { - logDebug('calls: ws close'); + ws.on('close', (event: WebSocketCloseEvent) => { + logDebug('calls: ws close, code:', event?.code, 'reason:', event?.reason, 'message:', event?.message); + }); + + ws.on('open', (originalConnID: string, prevConnID: string, isReconnect: boolean) => { + if (isReconnect) { + logDebug('calls: ws reconnect, sending reconnect msg'); + ws.send('reconnect', { + channelID, + originalConnID, + prevConnID, + }); + } else { + logDebug('calls: ws open, sending join msg'); + ws.send('join', { + channelID, + title, + threadID: rootId, + }); + } }); ws.on('join', async () => { + logDebug('calls: join ack received, initializing connection'); let config; try { config = await client.getCallsConfig(); } catch (err) { - logError('FETCHING CALLS CONFIG:', getFullErrorMessage(err)); + logError('calls: fetching calls config:', getFullErrorMessage(err)); return; } @@ -232,7 +251,7 @@ export async function newConnection( try { iceConfigs.push(...await client.genTURNCredentials()); } catch (err) { - logWarning('failed to fetch TURN credentials:', getFullErrorMessage(err)); + logWarning('calls: failed to fetch TURN credentials:', getFullErrorMessage(err)); } } @@ -249,7 +268,7 @@ export async function newConnection( selectedAudioDevice: data.selectedAudioDevice, }; setAudioDeviceInfo(info); - logDebug('AudioDeviceChanged. info:', info); + logDebug('calls: AudioDeviceChanged, info:', info); // Auto switch to bluetooth the first time we connect to bluetooth, but not after. if (!btInitialized) { @@ -274,7 +293,7 @@ export async function newConnection( // Log for customer debugging. For the moment we're not changing output labels because of incall-manager iOS // limitations with how it reports Bluetooth -- namely that it doesn't, so we don't know when Bluetooth is // overriding the earpiece and/or headset. - logDebug('WiredHeadset plugged in. Data:', data); + logDebug('calls: WiredHeadset plugged in, data:', data); // iOS switches to the headset when we connect it, so turn off speakerphone to keep UI in sync. if (data.isPlugged) { @@ -308,20 +327,21 @@ export async function newConnection( rtcMonitor.on('mos', processMeanOpinionScore); peer.on('offer', (sdp) => { - logDebug(`local offer, sending: ${JSON.stringify(sdp)}`); + logDebug(`calls: local offer, sending: ${JSON.stringify(sdp)}`); ws.send('sdp', { data: deflate(JSON.stringify(sdp)), }, true); }); peer.on('answer', (sdp) => { - logDebug(`local answer, sending: ${JSON.stringify(sdp)}`); + logDebug(`calls: local answer, sending: ${JSON.stringify(sdp)}`); ws.send('sdp', { data: deflate(JSON.stringify(sdp)), }, true); }); peer.on('candidate', (candidate) => { + logDebug(`calls: local candidate: ${JSON.stringify(candidate)}`); ws.send('ice', { data: JSON.stringify(candidate), }); @@ -335,9 +355,9 @@ export async function newConnection( }); peer.on('stream', (remoteStream: MediaStream) => { - logDebug('new remote stream received', remoteStream.id); + logDebug('calls: new remote stream received', remoteStream.id); for (const track of remoteStream.getTracks()) { - logDebug('remote track', track.id); + logDebug('calls: remote track', track.id); } streams.push(remoteStream); @@ -354,25 +374,14 @@ export async function newConnection( }); }); - ws.on('open', (originalConnID: string, prevConnID: string, isReconnect: boolean) => { - if (isReconnect) { - logDebug('calls: ws reconnect, sending reconnect msg'); - ws.send('reconnect', { - channelID, - originalConnID, - prevConnID, - }); - } else { - ws.send('join', { - channelID, - title, - threadID: rootId, - }); - } - }); - ws.on('message', ({data}: { data: string }) => { const msg = JSON.parse(data); + if (!msg) { + return; + } + if (msg.type !== 'ping') { + logDebug('calls: remote signal', data); + } if (msg.type === 'answer' || msg.type === 'candidate' || msg.type === 'offer') { peer?.signal(data); } diff --git a/app/products/calls/connection/websocket_client.ts b/app/products/calls/connection/websocket_client.ts index 4f58daecc..73a865953 100644 --- a/app/products/calls/connection/websocket_client.ts +++ b/app/products/calls/connection/websocket_client.ts @@ -59,8 +59,8 @@ export class WebSocketClient extends EventEmitter { this.emit('error', err); }; - this.ws.onclose = () => { - this.emit('close'); + this.ws.onclose = (event: WebSocketCloseEvent) => { + this.emit('close', event); if (!this.closed) { this.reconnect(); } diff --git a/app/products/calls/connection/websocket_event_handlers.ts b/app/products/calls/connection/websocket_event_handlers.ts index 5168e4272..69322dae8 100644 --- a/app/products/calls/connection/websocket_event_handlers.ts +++ b/app/products/calls/connection/websocket_event_handlers.ts @@ -6,7 +6,7 @@ import {DeviceEventEmitter} from 'react-native'; import {fetchUsersByIds} from '@actions/remote/user'; import { callEnded, - callStarted, + callStarted, getCallsConfig, removeIncomingCall, setCallScreenOff, setCallScreenOn, @@ -20,6 +20,7 @@ import { userLeftCall, userReacted, } from '@calls/state'; +import {isMultiSessionSupported} from '@calls/utils'; import {WebsocketEvents} from '@constants'; import DatabaseManager from '@database/manager'; import {getCurrentUserId} from '@queries/servers/system'; @@ -32,6 +33,8 @@ import type { UserConnectedData, UserDisconnectedData, UserDismissedNotification, + UserJoinedData, + UserLeftData, UserMutedUnmutedData, UserRaiseUnraiseHandData, UserReactionData, @@ -39,31 +42,58 @@ import type { UserVoiceOnOffData, } from '@mattermost/calls/lib/types'; +// DEPRECATED in favour of user_joined (since v0.21.0) export const handleCallUserConnected = (serverUrl: string, msg: WebSocketMessage) => { + if (isMultiSessionSupported(getCallsConfig(serverUrl).version)) { + return; + } + // Load user model async (if needed). fetchUsersByIds(serverUrl, [msg.data.userID]); - userJoinedCall(serverUrl, msg.broadcast.channel_id, msg.data.userID); + // Pre v0.21.0, sessionID == userID + userJoinedCall(serverUrl, msg.broadcast.channel_id, msg.data.userID, msg.data.userID); }; +// DEPRECATED in favour of user_left (since v0.21.0) export const handleCallUserDisconnected = (serverUrl: string, msg: WebSocketMessage) => { + if (isMultiSessionSupported(getCallsConfig(serverUrl).version)) { + return; + } + + // pre v0.21.0, sessionID == userID userLeftCall(serverUrl, msg.broadcast.channel_id, msg.data.userID); }; +export const handleCallUserJoined = (serverUrl: string, msg: WebSocketMessage) => { + // Load user model async (if needed). + fetchUsersByIds(serverUrl, [msg.data.user_id]); + + userJoinedCall(serverUrl, msg.broadcast.channel_id, msg.data.user_id, msg.data.session_id); +}; + +export const handleCallUserLeft = (serverUrl: string, msg: WebSocketMessage) => { + userLeftCall(serverUrl, msg.broadcast.channel_id, msg.data.session_id); +}; + export const handleCallUserMuted = (serverUrl: string, msg: WebSocketMessage) => { - setUserMuted(serverUrl, msg.broadcast.channel_id, msg.data.userID, true); + // pre v0.21.0, sessionID == userID + setUserMuted(serverUrl, msg.broadcast.channel_id, msg.data.session_id || msg.data.userID, true); }; export const handleCallUserUnmuted = (serverUrl: string, msg: WebSocketMessage) => { - setUserMuted(serverUrl, msg.broadcast.channel_id, msg.data.userID, false); + // pre v0.21.0, sessionID == userID + setUserMuted(serverUrl, msg.broadcast.channel_id, msg.data.session_id || msg.data.userID, false); }; export const handleCallUserVoiceOn = (msg: WebSocketMessage) => { - setUserVoiceOn(msg.broadcast.channel_id, msg.data.userID, true); + // pre v0.21.0, sessionID == userID + setUserVoiceOn(msg.broadcast.channel_id, msg.data.session_id || msg.data.userID, true); }; export const handleCallUserVoiceOff = (msg: WebSocketMessage) => { - setUserVoiceOn(msg.broadcast.channel_id, msg.data.userID, false); + // pre v0.21.0, sessionID == userID + setUserVoiceOn(msg.broadcast.channel_id, msg.data.session_id || msg.data.userID, false); }; export const handleCallStarted = (serverUrl: string, msg: WebSocketMessage) => { @@ -73,7 +103,7 @@ export const handleCallStarted = (serverUrl: string, msg: WebSocketMessage) => { - setCallScreenOn(serverUrl, msg.broadcast.channel_id, msg.data.userID); + // pre v0.21.0, sessionID == userID + setCallScreenOn(serverUrl, msg.broadcast.channel_id, msg.data.session_id || msg.data.userID); }; export const handleCallScreenOff = (serverUrl: string, msg: WebSocketMessage) => { - setCallScreenOff(serverUrl, msg.broadcast.channel_id); + // pre v0.21.0, sessionID == userID + setCallScreenOff(serverUrl, msg.broadcast.channel_id, msg.data.session_id || msg.data.userID); }; export const handleCallUserRaiseHand = (serverUrl: string, msg: WebSocketMessage) => { - setRaisedHand(serverUrl, msg.broadcast.channel_id, msg.data.userID, msg.data.raised_hand); + // pre v0.21.0, sessionID == userID + setRaisedHand(serverUrl, msg.broadcast.channel_id, msg.data.session_id || msg.data.userID, msg.data.raised_hand); }; export const handleCallUserUnraiseHand = (serverUrl: string, msg: WebSocketMessage) => { - setRaisedHand(serverUrl, msg.broadcast.channel_id, msg.data.userID, msg.data.raised_hand); + // pre v0.21.0, sessionID == userID + setRaisedHand(serverUrl, msg.broadcast.channel_id, msg.data.session_id || msg.data.userID, msg.data.raised_hand); }; export const handleCallUserReacted = (serverUrl: string, msg: WebSocketMessage) => { + // pre v0.21.0, sessionID == userID + if (!isMultiSessionSupported(getCallsConfig(serverUrl).version)) { + msg.data.session_id = msg.data.user_id; + } + userReacted(serverUrl, msg.broadcast.channel_id, msg.data); }; diff --git a/app/products/calls/observers/index.ts b/app/products/calls/observers/index.ts index 3acefafd3..a4a1a616a 100644 --- a/app/products/calls/observers/index.ts +++ b/app/products/calls/observers/index.ts @@ -3,11 +3,22 @@ import {distinctUntilChanged, switchMap, combineLatest, Observable, of as of$} from 'rxjs'; -import {observeCallsConfig, observeCallsState} from '@calls/state'; +import { + observeCallsConfig, + observeCallsState, + observeChannelsWithCalls, + observeCurrentCall, + observeIncomingCalls, +} from '@calls/state'; +import {fillUserModels, userIds} from '@calls/utils'; import {License} from '@constants'; +import DatabaseManager from '@database/manager'; import {observeConfigValue, observeLicense} from '@queries/servers/system'; +import {queryUsersById} from '@queries/servers/user'; +import UserModel from '@typings/database/models/servers/user'; import {isMinimumServerVersion} from '@utils/helpers'; +import type {CallSession} from '@calls/types/calls'; import type {Database} from '@nozbe/watermelondb'; export type LimitRestrictedInfo = { @@ -44,7 +55,7 @@ export const observeIsCallLimitRestricted = (database: Database, serverUrl: stri distinctUntilChanged(), ); const callNumOfParticipants = observeCallsState(serverUrl).pipe( - switchMap((cs) => of$(Object.keys(cs.calls[channelId]?.participants || {}).length)), + switchMap((cs) => of$(Object.keys(cs.calls[channelId]?.sessions || {}).length)), distinctUntilChanged(), ); const isCloud = observeLicense(database).pipe( @@ -65,3 +76,59 @@ export const observeIsCallLimitRestricted = (database: Database, serverUrl: stri prev.limitRestricted === curr.limitRestricted && prev.maxParticipants === curr.maxParticipants && prev.isCloudStarter === curr.isCloudStarter), ) as Observable; }; + +export const observeCurrentSessionsDict = () => { + const currentCall = observeCurrentCall(); + const database = currentCall.pipe( + switchMap((call) => of$(call ? call.serverUrl : '')), + distinctUntilChanged(), + switchMap((url) => of$(DatabaseManager.serverDatabases[url]?.database)), + ); + + return combineLatest([database, currentCall]).pipe( + switchMap(([db, call]) => (db && call ? queryUsersById(db, userIds(Object.values(call.sessions))).observeWithColumns(['nickname', 'username', 'first_name', 'last_name', 'last_picture_update']) : of$([])).pipe( + + // We now have a UserModel[] one for each userId, but we need the session dictionary with user models + // eslint-disable-next-line max-nested-callbacks + switchMap((ps: UserModel[]) => of$(fillUserModels(call?.sessions || {}, ps))), + )), + ) as Observable>; +}; + +export const observeCallStateInChannel = (serverUrl: string, database: Database, channelId: Observable) => { + const isCallInCurrentChannel = combineLatest([channelId, observeChannelsWithCalls(serverUrl)]).pipe( + switchMap(([id, calls]) => of$(Boolean(calls[id]))), + distinctUntilChanged(), + ); + const currentCall = observeCurrentCall(); + const ccChannelId = currentCall.pipe( + switchMap((call) => of$(call?.channelId)), + distinctUntilChanged(), + ); + const isInACall = currentCall.pipe( + switchMap((call) => of$(Boolean(call?.connected))), + distinctUntilChanged(), + ); + const dismissed = combineLatest([channelId, observeCallsState(serverUrl)]).pipe( + switchMap(([id, state]) => of$(Boolean(state.calls[id]?.dismissed[state.myUserId]))), + distinctUntilChanged(), + ); + const isInCurrentChannelCall = combineLatest([channelId, ccChannelId]).pipe( + switchMap(([id, ccId]) => of$(id === ccId)), + distinctUntilChanged(), + ); + const showJoinCallBanner = combineLatest([isCallInCurrentChannel, dismissed, isInCurrentChannelCall]).pipe( + switchMap(([isCall, dism, inCurrCall]) => of$(Boolean(isCall && !dism && !inCurrCall))), + distinctUntilChanged(), + ); + const showIncomingCalls = observeIncomingCalls().pipe( + switchMap((ics) => of$(ics.incomingCalls.length > 0)), + distinctUntilChanged(), + ); + + return { + showJoinCallBanner, + isInACall, + showIncomingCalls, + }; +}; diff --git a/app/products/calls/screens/call_screen/call_screen.tsx b/app/products/calls/screens/call_screen/call_screen.tsx index 313889e7c..acbaff5de 100644 --- a/app/products/calls/screens/call_screen/call_screen.tsx +++ b/app/products/calls/screens/call_screen/call_screen.tsx @@ -41,7 +41,7 @@ import { useCallsConfig, useIncomingCalls, } from '@calls/state'; -import {getHandsRaised, makeCallsTheme, sortParticipants} from '@calls/utils'; +import {getHandsRaised, makeCallsTheme, sortSessions} from '@calls/utils'; import CompassIcon from '@components/compass_icon'; import FormattedText from '@components/formatted_text'; import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item'; @@ -68,7 +68,7 @@ import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; import {displayUsername} from '@utils/user'; -import type {CallParticipant, CallsTheme, CurrentCall} from '@calls/types/calls'; +import type {CallSession, CallsTheme, CurrentCall} from '@calls/types/calls'; import type {AvailableScreens} from '@typings/screens/navigation'; const avatarL = 96; @@ -79,7 +79,7 @@ const usernameM = 92; export type Props = { componentId: AvailableScreens; currentCall: CurrentCall | null; - participantsDict: Dictionary; + sessionsDict: Dictionary; micPermissionsGranted: boolean; teammateNameDisplay: string; fromThreadScreen?: boolean; @@ -317,7 +317,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: CallsTheme) => ({ const CallScreen = ({ componentId, currentCall, - participantsDict, + sessionsDict, micPermissionsGranted, teammateNameDisplay, fromThreadScreen, @@ -339,13 +339,13 @@ const CallScreen = ({ const [centerUsers, setCenterUsers] = useState(false); const [layout, setLayout] = useState(null); - const myParticipant = currentCall?.participants[currentCall.myUserId]; + const mySession = currentCall?.sessions[currentCall.mySessionId]; const micPermissionsError = !micPermissionsGranted && !currentCall?.micPermissionsErrorDismissed; const screenShareOn = Boolean(currentCall?.screenOn); const isLandscape = width > height; const smallerAvatar = isLandscape || screenShareOn; const avatarSize = smallerAvatar ? avatarM : avatarL; - const numParticipants = Object.keys(participantsDict).length; + const numSessions = Object.keys(sessionsDict).length; const showIncomingCalls = incomingCalls.incomingCalls.length > 0; const callThreadOptionTitle = intl.formatMessage({id: 'mobile.calls_call_thread', defaultMessage: 'Call Thread'}); @@ -389,12 +389,12 @@ const CallScreen = ({ }, []); const muteUnmuteHandler = useCallback(() => { - if (myParticipant?.muted) { + if (mySession?.muted) { unmuteMyself(); } else { muteMyself(); } - }, [myParticipant?.muted]); + }, [mySession?.muted]); const toggleReactions = useCallback(() => { setShowReactions((prev) => !prev); @@ -450,7 +450,7 @@ const CallScreen = ({ // The user should receive a recording alert if all of the following conditions apply: // - Recording has started, recording has not ended - const isHost = Boolean(currentCall?.hostId === myParticipant?.id); + const isHost = Boolean(currentCall?.hostId === mySession?.userId); const recording = Boolean(currentCall?.recState?.start_at && !currentCall.recState.end_at); if (recording) { recordingAlert(isHost, intl); @@ -541,8 +541,8 @@ const CallScreen = ({ const avatarCellWidth = usernameSize + 20; // name width + padding const perRow = Math.floor(layout.width / avatarCellWidth); - const totalHeight = Math.ceil(numParticipants / perRow) * avatarCellHeight; - const totalWidth = numParticipants * avatarCellWidth; + const totalHeight = Math.ceil(numSessions / perRow) * avatarCellHeight; + const totalWidth = numSessions * avatarCellWidth; // If screenShareOn, we care about width, otherwise we care about height. if ((screenShareOn && totalWidth > layout.width) || (!screenShareOn && totalHeight > layout.height)) { @@ -550,13 +550,13 @@ const CallScreen = ({ } else { setCenterUsers(true); } - }, [layout, numParticipants]); + }, [layout, numSessions]); const onLayout = useCallback((e: LayoutChangeEvent) => { setLayout(e.nativeEvent.layout); }, []); - if (!currentCall || !myParticipant) { + if (!currentCall || !mySession) { return null; } @@ -576,7 +576,7 @@ const CallScreen = ({ } @@ -584,8 +584,8 @@ const CallScreen = ({ ); } - const raisedHands = getHandsRaised(participantsDict); - const participants = sortParticipants(intl.locale, teammateNameDisplay, participantsDict, currentCall.screenOn); + const raisedHands = getHandsRaised(sessionsDict); + const sessions = sortSessions(intl.locale, teammateNameDisplay, sessionsDict, currentCall.screenOn); let usersList = null; if (!screenShareOn || !isLandscape) { usersList = ( @@ -601,20 +601,20 @@ const CallScreen = ({ onPress={toggleControlsInLandscape} style={style.users} > - {participants.map((user) => { + {sessions.map((sess) => { return ( @@ -623,12 +623,12 @@ const CallScreen = ({ style={[style.username, smallerAvatar && style.usernameShort]} numberOfLines={1} > - {displayUsername(user.userModel, intl.locale, teammateNameDisplay)} - {user.id === myParticipant.id && + {displayUsername(sess.userModel, intl.locale, teammateNameDisplay)} + {sess.sessionId === mySession.sessionId && ` ${intl.formatMessage({id: 'mobile.calls_you', defaultMessage: '(you)'})}` } - {user.id === currentCall.hostId && } + {sess.userId === currentCall.hostId && } ); })} @@ -668,7 +668,7 @@ const CallScreen = ({ /> {showReactions && - + } {!isLandscape && - {myParticipant.muted ? UnmuteText : MuteText} + {mySession.muted ? UnmuteText : MuteText} } @@ -813,18 +813,18 @@ const CallScreen = ({ onPress={muteUnmuteHandler} > - {myParticipant.muted ? UnmuteText : MuteText} + {mySession.muted ? UnmuteText : MuteText} } {(isLandscape || !isHost) && diff --git a/app/products/calls/screens/call_screen/index.ts b/app/products/calls/screens/call_screen/index.ts index 89fc899d4..09db2fd69 100644 --- a/app/products/calls/screens/call_screen/index.ts +++ b/app/products/calls/screens/call_screen/index.ts @@ -1,17 +1,15 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import withObservables from '@nozbe/with-observables'; -import {combineLatest, of as of$} from 'rxjs'; +import {withObservables} from '@nozbe/watermelondb/react'; +import {of as of$} from 'rxjs'; import {distinctUntilChanged, switchMap} from 'rxjs/operators'; +import {observeCurrentSessionsDict} from '@calls/observers'; import CallScreen from '@calls/screens/call_screen/call_screen'; import {observeCurrentCall, observeGlobalCallsState} from '@calls/state'; import DatabaseManager from '@database/manager'; -import {observeTeammateNameDisplay, queryUsersById} from '@queries/servers/user'; - -import type {CallParticipant} from '@calls/types/calls'; -import type UserModel from '@typings/database/models/servers/user'; +import {observeTeammateNameDisplay} from '@queries/servers/user'; const enhanced = withObservables([], () => { const currentCall = observeCurrentCall(); @@ -20,20 +18,6 @@ const enhanced = withObservables([], () => { distinctUntilChanged(), switchMap((url) => of$(DatabaseManager.serverDatabases[url]?.database)), ); - - // TODO: to be optimized https://mattermost.atlassian.net/browse/MM-49338 - const participantsDict = combineLatest([database, currentCall]).pipe( - switchMap(([db, call]) => (db && call ? queryUsersById(db, Object.keys(call.participants)).observeWithColumns(['nickname', 'username', 'first_name', 'last_name', 'last_picture_update']) : of$([])).pipe( - // eslint-disable-next-line max-nested-callbacks - switchMap((ps: UserModel[]) => of$(ps.reduce((accum, cur) => { - accum[cur.id] = { - ...call!.participants[cur.id], - userModel: cur, - }; - return accum; - }, {} as Dictionary))), - )), - ); const micPermissionsGranted = observeGlobalCallsState().pipe( switchMap((gs) => of$(gs.micPermissionsGranted)), distinctUntilChanged(), @@ -45,7 +29,7 @@ const enhanced = withObservables([], () => { return { currentCall, - participantsDict, + sessionsDict: observeCurrentSessionsDict(), micPermissionsGranted, teammateNameDisplay, }; diff --git a/app/products/calls/screens/call_screen/raised_hand_banner.tsx b/app/products/calls/screens/call_screen/raised_hand_banner.tsx index fdbc5e3f8..ca9d1e739 100644 --- a/app/products/calls/screens/call_screen/raised_hand_banner.tsx +++ b/app/products/calls/screens/call_screen/raised_hand_banner.tsx @@ -12,11 +12,11 @@ import {useTheme} from '@context/theme'; import {makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; -import type {CallParticipant, CallsTheme} from '@calls/types/calls'; +import type {CallSession, CallsTheme} from '@calls/types/calls'; export type Props = { - raisedHands: CallParticipant[]; - currentUserId: string; + raisedHands: CallSession[]; + sessionId: string; teammateNameDisplay: string; } @@ -53,7 +53,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: CallsTheme) => ({ }, })); -export const RaisedHandBanner = ({raisedHands, currentUserId, teammateNameDisplay}: Props) => { +export const RaisedHandBanner = ({raisedHands, sessionId, teammateNameDisplay}: Props) => { const intl = useIntl(); const theme = useTheme(); const callsTheme = useMemo(() => makeCallsTheme(theme), [theme]); @@ -63,7 +63,7 @@ export const RaisedHandBanner = ({raisedHands, currentUserId, teammateNameDispla return ; } - const names = getHandsRaisedNames(raisedHands, currentUserId, intl.locale, teammateNameDisplay, intl); + const names = getHandsRaisedNames(raisedHands, sessionId, intl.locale, teammateNameDisplay, intl); return ( diff --git a/app/products/calls/state/actions.test.ts b/app/products/calls/state/actions.test.ts index 5f8078df9..db5d2d2f9 100644 --- a/app/products/calls/state/actions.test.ts +++ b/app/products/calls/state/actions.test.ts @@ -102,9 +102,9 @@ jest.mock('react-native-navigation', () => ({ const call1: Call = { id: 'call1', - participants: { - 'user-1': {id: 'user-1', muted: false, raisedHand: 0}, - 'user-2': {id: 'user-2', muted: true, raisedHand: 0}, + sessions: { + session1: {sessionId: 'session1', userId: 'user-1', muted: false, raisedHand: 0}, + session2: {sessionId: 'session2', userId: 'user-2', muted: true, raisedHand: 0}, }, channelId: 'channel-1', startTime: 123, @@ -116,9 +116,9 @@ const call1: Call = { }; const call2: Call = { id: 'call2', - participants: { - 'user-3': {id: 'user-3', muted: false, raisedHand: 0}, - 'user-4': {id: 'user-4', muted: true, raisedHand: 0}, + sessions: { + session3: {sessionId: 'session3', userId: 'user-3', muted: false, raisedHand: 0}, + session4: {sessionId: 'session4', userId: 'user-4', muted: true, raisedHand: 0}, }, channelId: 'channel-2', startTime: 123, @@ -130,9 +130,9 @@ const call2: Call = { }; const call3: Call = { id: 'call3', - participants: { - 'user-5': {id: 'user-5', muted: false, raisedHand: 0}, - 'user-6': {id: 'user-6', muted: true, raisedHand: 0}, + sessions: { + session5: {sessionId: 'session5', userId: 'user-5', muted: false, raisedHand: 0}, + session6: {sessionId: 'session6', userId: 'user-6', muted: true, raisedHand: 0}, }, channelId: 'channel-3', startTime: 123, @@ -144,8 +144,8 @@ const call3: Call = { }; const callDM: Call = { id: 'callDM', - participants: { - 'user-5': {id: 'user-5', muted: false, raisedHand: 0}, + sessions: { + session5: {sessionId: 'session5', userId: 'user-5', muted: false, raisedHand: 0}, }, channelId: 'channel-private', startTime: 123, @@ -206,10 +206,10 @@ describe('useCallsState', () => { }; const testNewCall1 = { ...call1, - participants: { - 'user-1': {id: 'user-1', muted: false, raisedHand: 0}, - 'user-2': {id: 'user-2', muted: true, raisedHand: 0}, - 'user-3': {id: 'user-3', muted: false, raisedHand: 123}, + sessions: { + session1: {sessionId: 'session1', userId: 'user-1', muted: false, raisedHand: 0}, + session2: {sessionId: 'session2', userId: 'user-2', muted: true, raisedHand: 0}, + session3: {sessionId: 'session3', userId: 'user-3', muted: false, raisedHand: 123}, }, }; const test = { @@ -279,10 +279,10 @@ describe('useCallsState', () => { const expectedCallsState = { 'channel-1': { id: 'call1', - participants: { - 'user-1': {id: 'user-1', muted: false, raisedHand: 0}, - 'user-2': {id: 'user-2', muted: true, raisedHand: 0}, - 'user-3': {id: 'user-3', muted: true, raisedHand: 0}, + sessions: { + session1: {sessionId: 'session1', userId: 'user-1', muted: false, raisedHand: 0}, + session2: {sessionId: 'session2', userId: 'user-2', muted: true, raisedHand: 0}, + session3: {sessionId: 'session3', userId: 'user-3', muted: true, raisedHand: 0}, }, channelId: 'channel-1', startTime: 123, @@ -313,11 +313,11 @@ describe('useCallsState', () => { assert.deepEqual(result.current[2], initialCurrentCallState); // test - act(() => userJoinedCall('server1', 'channel-1', 'user-3')); + act(() => userJoinedCall('server1', 'channel-1', 'user-3', 'session3')); assert.deepEqual((result.current[0] as CallsState).calls, expectedCallsState); assert.deepEqual(result.current[1], expectedChannelsWithCallsState); assert.deepEqual(result.current[2], expectedCurrentCallState); - act(() => userJoinedCall('server1', 'invalid-channel', 'user-1')); + act(() => userJoinedCall('server1', 'invalid-channel', 'user-1', 'session1')); assert.deepEqual((result.current[0] as CallsState).calls, expectedCallsState); assert.deepEqual(result.current[1], expectedChannelsWithCallsState); assert.deepEqual(result.current[2], expectedCurrentCallState); @@ -341,8 +341,8 @@ describe('useCallsState', () => { const expectedCallsState = { 'channel-1': { id: 'call1', - participants: { - 'user-2': {id: 'user-2', muted: true, raisedHand: 0}, + sessions: { + session2: {sessionId: 'session2', userId: 'user-2', muted: true, raisedHand: 0}, }, channelId: 'channel-1', startTime: 123, @@ -373,11 +373,11 @@ describe('useCallsState', () => { assert.deepEqual(result.current[2], initialCurrentCallState); // test - act(() => userLeftCall('server1', 'channel-1', 'user-1')); + act(() => userLeftCall('server1', 'channel-1', 'session1')); assert.deepEqual((result.current[0] as CallsState).calls, expectedCallsState); assert.deepEqual(result.current[1], expectedChannelsWithCallsState); assert.deepEqual(result.current[2], expectedCurrentCallState); - act(() => userLeftCall('server1', 'invalid-channel', 'user-2')); + act(() => userLeftCall('server1', 'invalid-channel', 'session2')); assert.deepEqual((result.current[0] as CallsState).calls, expectedCallsState); assert.deepEqual(result.current[1], expectedChannelsWithCallsState); assert.deepEqual(result.current[2], expectedCurrentCallState); @@ -389,7 +389,7 @@ describe('useCallsState', () => { calls: { 'channel-1': { ...call1, - screenOn: 'user-1', + screenOn: 'session1', }, }, }; @@ -402,13 +402,13 @@ describe('useCallsState', () => { serverUrl: 'server1', myUserId: 'myUserId', ...call1, - screenOn: 'user-1', + screenOn: 'session1', }; const expectedCallsState = { 'channel-1': { id: 'call1', - participants: { - 'user-2': {id: 'user-2', muted: true, raisedHand: 0}, + sessions: { + session2: {sessionId: 'session2', userId: 'user-2', muted: true, raisedHand: 0}, }, channelId: 'channel-1', startTime: 123, @@ -439,7 +439,7 @@ describe('useCallsState', () => { assert.deepEqual(result.current[2], initialCurrentCallState); // test - act(() => userLeftCall('server1', 'channel-1', 'user-1')); + act(() => userLeftCall('server1', 'channel-1', 'session1')); assert.deepEqual((result.current[0] as CallsState).calls, expectedCallsState); assert.deepEqual(result.current[1], expectedChannelsWithCallsState); assert.deepEqual(result.current[2], expectedCurrentCallState); @@ -534,22 +534,22 @@ describe('useCallsState', () => { assert.deepEqual(result.current[2], initialCurrentCallState); // test - act(() => setUserMuted('server1', 'channel-1', 'user-1', true)); - assert.deepEqual((result.current[0] as CallsState).calls['channel-1'].participants['user-1'].muted, true); - assert.deepEqual((result.current[2] as CurrentCall | null)?.participants['user-1'].muted, true); + act(() => setUserMuted('server1', 'channel-1', 'session1', true)); + assert.deepEqual((result.current[0] as CallsState).calls['channel-1'].sessions.session1.muted, true); + assert.deepEqual((result.current[2] as CurrentCall | null)?.sessions.session1.muted, true); act(() => { - setUserMuted('server1', 'channel-1', 'user-1', false); - setUserMuted('server1', 'channel-1', 'user-2', false); + setUserMuted('server1', 'channel-1', 'session1', false); + setUserMuted('server1', 'channel-1', 'session2', false); }); - assert.deepEqual((result.current[0] as CallsState).calls['channel-1'].participants['user-1'].muted, false); - assert.deepEqual((result.current[0] as CallsState).calls['channel-1'].participants['user-2'].muted, false); - assert.deepEqual((result.current[2] as CurrentCall | null)?.participants['user-1'].muted, false); - assert.deepEqual((result.current[2] as CurrentCall | null)?.participants['user-2'].muted, false); - act(() => setUserMuted('server1', 'channel-1', 'user-2', true)); - assert.deepEqual((result.current[0] as CallsState).calls['channel-1'].participants['user-2'].muted, true); - assert.deepEqual((result.current[2] as CurrentCall | null)?.participants['user-2'].muted, true); + assert.deepEqual((result.current[0] as CallsState).calls['channel-1'].sessions.session1.muted, false); + assert.deepEqual((result.current[0] as CallsState).calls['channel-1'].sessions.session2.muted, false); + assert.deepEqual((result.current[2] as CurrentCall | null)?.sessions.session1.muted, false); + assert.deepEqual((result.current[2] as CurrentCall | null)?.sessions.session2.muted, false); + act(() => setUserMuted('server1', 'channel-1', 'session2', true)); + assert.deepEqual((result.current[0] as CallsState).calls['channel-1'].sessions.session2.muted, true); + assert.deepEqual((result.current[2] as CurrentCall | null)?.sessions.session2.muted, true); assert.deepEqual(result.current[0], initialCallsState); - act(() => setUserMuted('server1', 'invalid-channel', 'user-1', true)); + act(() => setUserMuted('server1', 'invalid-channel', 'session1', true)); assert.deepEqual(result.current[0], initialCallsState); }); @@ -580,11 +580,11 @@ describe('useCallsState', () => { assert.deepEqual(result.current[2], initialCurrentCallState); // test - act(() => setCallScreenOn('server1', 'channel-1', 'user-1')); - assert.deepEqual((result.current[0] as CallsState).calls['channel-1'].screenOn, 'user-1'); + act(() => setCallScreenOn('server1', 'channel-1', 'session1')); + assert.deepEqual((result.current[0] as CallsState).calls['channel-1'].screenOn, 'session1'); assert.deepEqual(result.current[1], initialChannelsWithCallsState); - assert.deepEqual((result.current[2] as CurrentCall).screenOn, 'user-1'); - act(() => setCallScreenOff('server1', 'channel-1')); + assert.deepEqual((result.current[2] as CurrentCall).screenOn, 'session1'); + act(() => setCallScreenOff('server1', 'channel-1', 'session1')); assert.deepEqual(result.current[0], initialCallsState); assert.deepEqual(result.current[1], initialChannelsWithCallsState); assert.deepEqual(result.current[2], initialCurrentCallState); @@ -592,7 +592,7 @@ describe('useCallsState', () => { assert.deepEqual(result.current[0], initialCallsState); assert.deepEqual(result.current[1], initialChannelsWithCallsState); assert.deepEqual(result.current[2], initialCurrentCallState); - act(() => setCallScreenOff('server1', 'invalid-channel')); + act(() => setCallScreenOff('server1', 'invalid-channel', 'session1')); assert.deepEqual(result.current[0], initialCallsState); assert.deepEqual(result.current[1], initialChannelsWithCallsState); assert.deepEqual(result.current[2], initialCurrentCallState); @@ -606,9 +606,9 @@ describe('useCallsState', () => { const expectedCalls = { 'channel-1': { id: 'call1', - participants: { - 'user-1': {id: 'user-1', muted: false, raisedHand: 0}, - 'user-2': {id: 'user-2', muted: true, raisedHand: 345}, + sessions: { + session1: {sessionId: 'session1', userId: 'user-1', muted: false, raisedHand: 0}, + session2: {sessionId: 'session2', userId: 'user-2', muted: true, raisedHand: 345}, }, channelId: 'channel-1', startTime: 123, @@ -643,16 +643,16 @@ describe('useCallsState', () => { assert.deepEqual(result.current[1], initialCurrentCallState); // test - act(() => setRaisedHand('server1', 'channel-1', 'user-2', 345)); + act(() => setRaisedHand('server1', 'channel-1', 'session2', 345)); assert.deepEqual((result.current[0] as CallsState).calls, expectedCalls); assert.deepEqual((result.current[1] as CurrentCall | null), expectedCurrentCallState); - act(() => setRaisedHand('server1', 'invalid-channel', 'user-1', 345)); + act(() => setRaisedHand('server1', 'invalid-channel', 'session1', 345)); assert.deepEqual((result.current[0] as CallsState).calls, expectedCalls); assert.deepEqual((result.current[1] as CurrentCall | null), expectedCurrentCallState); // unraise hand: - act(() => setRaisedHand('server1', 'channel-1', 'user-2', 0)); + act(() => setRaisedHand('server1', 'channel-1', 'session2', 0)); assert.deepEqual(result.current[0], initialCallsState); assert.deepEqual(result.current[1], initialCurrentCallState); }); @@ -665,9 +665,9 @@ describe('useCallsState', () => { }; const newCall1 = { ...call1, - participants: { - ...call1.participants, - myUserId: {id: 'myUserId', muted: true, raisedHand: 0}, + sessions: { + ...call1.sessions, + mySessionId: {sessionId: 'mySessionId', userId: 'myUserId', muted: true, raisedHand: 0}, }, }; const expectedCallsState = { @@ -682,6 +682,7 @@ describe('useCallsState', () => { connected: true, serverUrl: 'server1', myUserId: 'myUserId', + mySessionId: 'mySessionId', ...newCall1, }; @@ -696,14 +697,14 @@ describe('useCallsState', () => { // test act(() => { newCurrentCall('server1', 'channel-1', 'myUserId'); - userJoinedCall('server1', 'channel-1', 'myUserId'); + userJoinedCall('server1', 'channel-1', 'myUserId', 'mySessionId'); }); assert.deepEqual(result.current[0], expectedCallsState); assert.deepEqual(result.current[1], expectedCurrentCallState); act(() => { myselfLeftCall(); - userLeftCall('server1', 'channel-1', 'myUserId'); + userLeftCall('server1', 'channel-1', 'mySessionId'); }); assert.deepEqual(result.current[0], initialCallsState); assert.deepEqual(result.current[1], null); @@ -768,14 +769,14 @@ describe('useCallsState', () => { // test joining a call and setting url: act(() => newCurrentCall('server1', 'channel-1', 'myUserId')); - act(() => userJoinedCall('server1', 'channel-1', 'myUserId')); + act(() => userJoinedCall('server1', 'channel-1', 'myUserId', 'mySessionId')); assert.deepEqual((result.current[1] as CurrentCall | null)?.screenShareURL, ''); act(() => setScreenShareURL('testUrl')); assert.deepEqual((result.current[1] as CurrentCall | null)?.screenShareURL, 'testUrl'); act(() => { myselfLeftCall(); - userLeftCall('server1', 'channel-1', 'myUserId'); + userLeftCall('server1', 'channel-1', 'mySessionId'); setScreenShareURL('test'); }); assert.deepEqual(result.current[0], initialCallsState); @@ -790,9 +791,9 @@ describe('useCallsState', () => { }; const newCall1 = { ...call1, - participants: { - ...call1.participants, - myUserId: {id: 'myUserId', muted: true, raisedHand: 0}, + sessions: { + ...call1.sessions, + mySessionId: {sessionId: 'mySessionId', userId: 'myUserId', muted: true, raisedHand: 0}, }, }; const expectedCallsState = { @@ -813,7 +814,7 @@ describe('useCallsState', () => { // test act(() => newCurrentCall('server1', 'channel-1', 'myUserId')); - act(() => userJoinedCall('server1', 'channel-1', 'myUserId')); + act(() => userJoinedCall('server1', 'channel-1', 'myUserId', 'mySessionId')); assert.deepEqual((result.current[1] as CurrentCall | null)?.speakerphoneOn, false); act(() => setSpeakerPhone(true)); assert.deepEqual((result.current[1] as CurrentCall | null)?.speakerphoneOn, true); @@ -836,9 +837,9 @@ describe('useCallsState', () => { }; const newCall1 = { ...call1, - participants: { - ...call1.participants, - myUserId: {id: 'myUserId', muted: true, raisedHand: 0}, + sessions: { + ...call1.sessions, + mySessionId: {sessionId: 'mySessionId', userId: 'myUserId', muted: true, raisedHand: 0}, }, }; const expectedCallsState = { @@ -868,7 +869,7 @@ describe('useCallsState', () => { // test act(() => newCurrentCall('server1', 'channel-1', 'myUserId')); - act(() => userJoinedCall('server1', 'channel-1', 'myUserId')); + act(() => userJoinedCall('server1', 'channel-1', 'myUserId', 'mySessionId')); assert.deepEqual((result.current[1] as CurrentCall | null)?.audioDeviceInfo, defaultAudioDeviceInfo); act(() => setAudioDeviceInfo(newAudioDeviceInfo)); assert.deepEqual((result.current[1] as CurrentCall | null)?.audioDeviceInfo, newAudioDeviceInfo); @@ -890,9 +891,9 @@ describe('useCallsState', () => { }; const newCall1: Call = { ...call1, - participants: { - ...call1.participants, - myUserId: {id: 'myUserId', muted: true, raisedHand: 0}, + sessions: { + ...call1.sessions, + mySessionId: {sessionId: 'mySessionId', userId: 'myUserId', muted: true, raisedHand: 0}, }, }; const expectedCallsState: CallsState = { @@ -906,6 +907,7 @@ describe('useCallsState', () => { ...DefaultCurrentCall, serverUrl: 'server1', myUserId: 'myUserId', + mySessionId: 'mySessionId', connected: true, ...newCall1, }; @@ -930,7 +932,7 @@ describe('useCallsState', () => { act(() => { setMicPermissionsGranted(false); newCurrentCall('server1', 'channel-1', 'myUserId'); - userJoinedCall('server1', 'channel-1', 'myUserId'); + userJoinedCall('server1', 'channel-1', 'myUserId', 'mySessionId'); }); assert.deepEqual(result.current[0], expectedCallsState); assert.deepEqual(result.current[1], expectedCurrentCallState); @@ -950,7 +952,7 @@ describe('useCallsState', () => { act(() => { myselfLeftCall(); - userLeftCall('server1', 'channel-1', 'myUserId'); + userLeftCall('server1', 'channel-1', 'mySessionId'); }); assert.deepEqual(result.current[0], initialCallsState); assert.deepEqual(result.current[1], null); @@ -964,9 +966,9 @@ describe('useCallsState', () => { }; const newCall1: Call = { ...call1, - participants: { - ...call1.participants, - myUserId: {id: 'myUserId', muted: true, raisedHand: 0}, + sessions: { + ...call1.sessions, + mySessionId: {sessionId: 'mySessionId', userId: 'myUserId', muted: true, raisedHand: 0}, }, }; const expectedCallsState: CallsState = { @@ -980,6 +982,7 @@ describe('useCallsState', () => { ...DefaultCurrentCall, serverUrl: 'server1', myUserId: 'myUserId', + mySessionId: 'mySessionId', connected: true, ...newCall1, }; @@ -995,7 +998,7 @@ describe('useCallsState', () => { // join call act(() => { newCurrentCall('server1', 'channel-1', 'myUserId'); - userJoinedCall('server1', 'channel-1', 'myUserId'); + userJoinedCall('server1', 'channel-1', 'myUserId', 'mySessionId'); }); assert.deepEqual(result.current[0], expectedCallsState); assert.deepEqual(result.current[1], currentCallNoAlertNoDismissed); @@ -1058,14 +1061,14 @@ describe('useCallsState', () => { assert.deepEqual(result.current[1], initialCurrentCallState); // test - act(() => setUserVoiceOn('channel-1', 'user-1', true)); - assert.deepEqual(result.current[1], {...initialCurrentCallState, voiceOn: {'user-1': true}}); + act(() => setUserVoiceOn('channel-1', 'session1', true)); + assert.deepEqual(result.current[1], {...initialCurrentCallState, voiceOn: {session1: true}}); assert.deepEqual(result.current[0], initialCallsState); - act(() => setUserVoiceOn('channel-1', 'user-2', true)); - assert.deepEqual(result.current[1], {...initialCurrentCallState, voiceOn: {'user-1': true, 'user-2': true}}); + act(() => setUserVoiceOn('channel-1', 'session2', true)); + assert.deepEqual(result.current[1], {...initialCurrentCallState, voiceOn: {session1: true, session2: true}}); assert.deepEqual(result.current[0], initialCallsState); - act(() => setUserVoiceOn('channel-1', 'user-1', false)); - assert.deepEqual(result.current[1], {...initialCurrentCallState, voiceOn: {'user-2': true}}); + act(() => setUserVoiceOn('channel-1', 'session1', false)); + assert.deepEqual(result.current[1], {...initialCurrentCallState, voiceOn: {session2: true}}); assert.deepEqual(result.current[0], initialCallsState); // test that voice state is cleared on reconnect @@ -1128,22 +1131,24 @@ describe('useCallsState', () => { {name: 'smile', latestTimestamp: 202, count: 1, literal: undefined}, {name: '+1', latestTimestamp: 145, count: 2, literal: undefined}, ], - participants: { - ...initialCurrentCallState.participants, - 'user-1': { - ...initialCurrentCallState.participants['user-1'], + sessions: { + ...initialCurrentCallState.sessions, + session1: { + ...initialCurrentCallState.sessions.session1, reaction: { user_id: 'user-1', + session_id: 'session1', emoji: {name: 'smile', unified: 'something'}, timestamp: 202, }, }, - 'user-2': { - ...initialCurrentCallState.participants['user-2'], + session2: { + ...initialCurrentCallState.sessions.session2, reaction: { user_id: 'user-2', + session_id: 'session2', emoji: {name: '+1', unified: 'something'}, - timestamp: 123, + timestamp: 145, }, }, }, @@ -1164,16 +1169,19 @@ describe('useCallsState', () => { act(() => { userReacted('server1', 'channel-1', { user_id: 'user-2', + session_id: 'session2', emoji: {name: '+1', unified: 'something'}, timestamp: 123, }); userReacted('server1', 'channel-1', { - user_id: 'user-1', + user_id: 'user-2', + session_id: 'session2', emoji: {name: '+1', unified: 'something'}, timestamp: 145, }); userReacted('server1', 'channel-1', { user_id: 'user-1', + session_id: 'session1', emoji: {name: 'smile', unified: 'something'}, timestamp: 202, }); @@ -1319,8 +1327,8 @@ describe('useCallsState', () => { }; const callIStarted: Call = { id: 'callIStartedid', - participants: { - 'user-5': {id: 'user-5', muted: false, raisedHand: 0}, + sessions: { + session5: {sessionId: 'session5', userId: 'user-5', muted: false, raisedHand: 0}, }, channelId: 'channel-private2', startTime: 123, @@ -1332,7 +1340,7 @@ describe('useCallsState', () => { }; const callImIn: Call = { id: 'callImInId', - participants: {}, + sessions: {}, channelId: 'channel-private2', startTime: 123, screenOn: '', diff --git a/app/products/calls/state/actions.ts b/app/products/calls/state/actions.ts index f63e9fd6e..ea1d14498 100644 --- a/app/products/calls/state/actions.ts +++ b/app/products/calls/state/actions.ts @@ -219,7 +219,7 @@ export const setCallForChannel = (serverUrl: string, channelId: string, enabled? } }; -export const userJoinedCall = (serverUrl: string, channelId: string, userId: string) => { +export const userJoinedCall = (serverUrl: string, channelId: string, userId: string, sessionId: string) => { const callsState = getCallsState(serverUrl); if (!callsState.calls[channelId]) { return; @@ -227,10 +227,11 @@ export const userJoinedCall = (serverUrl: string, channelId: string, userId: str const nextCall = { ...callsState.calls[channelId], - participants: {...callsState.calls[channelId].participants}, + sessions: {...callsState.calls[channelId].sessions}, }; - nextCall.participants[userId] = { - id: userId, + nextCall.sessions[sessionId] = { + userId, + sessionId, muted: true, raisedHand: 0, }; @@ -242,46 +243,48 @@ export const userJoinedCall = (serverUrl: string, channelId: string, userId: str const currentCall = getCurrentCall(); if (currentCall && currentCall.channelId === channelId) { const voiceOn = {...currentCall.voiceOn}; - delete voiceOn[userId]; + delete voiceOn[sessionId]; const nextCurrentCall = { ...currentCall, - participants: {...currentCall.participants, [userId]: nextCall.participants[userId]}, + sessions: {...currentCall.sessions, [sessionId]: nextCall.sessions[sessionId]}, voiceOn, }; // If this is the currentUser, that means we've connected to the call we created. - if (userId === nextCurrentCall.myUserId) { + if (userId === nextCurrentCall.myUserId && !nextCurrentCall.connected) { nextCurrentCall.connected = true; + nextCurrentCall.mySessionId = sessionId; } setCurrentCall(nextCurrentCall); } + // We've joined (from whatever client), so remove that call's notification if (userId === callsState.myUserId) { removeIncomingCall(serverUrl, callsState.calls[channelId].id, channelId); } }; -export const userLeftCall = (serverUrl: string, channelId: string, userId: string) => { +export const userLeftCall = (serverUrl: string, channelId: string, sessionId: string) => { const callsState = getCallsState(serverUrl); - if (!callsState.calls[channelId]?.participants[userId]) { + if (!callsState.calls[channelId]?.sessions[sessionId]) { return; } const nextCall = { ...callsState.calls[channelId], - participants: {...callsState.calls[channelId].participants}, + sessions: {...callsState.calls[channelId].sessions}, }; - delete nextCall.participants[userId]; + delete nextCall.sessions[sessionId]; // If they were screensharing, remove that. - if (nextCall.screenOn === userId) { + if (nextCall.screenOn === sessionId) { nextCall.screenOn = ''; } const nextCalls = {...callsState.calls}; - if (Object.keys(nextCall.participants).length === 0) { + if (Object.keys(nextCall.sessions).length === 0) { delete nextCalls[channelId]; const callId = callsState.calls[channelId].id; @@ -303,25 +306,24 @@ export const userLeftCall = (serverUrl: string, channelId: string, userId: strin return; } - // Was the user me? - if (userId === callsState.myUserId) { + if (sessionId === currentCall.mySessionId) { myselfLeftCall(); return; } // Clear them from the voice list const voiceOn = {...currentCall.voiceOn}; - delete voiceOn[userId]; + delete voiceOn[sessionId]; const nextCurrentCall = { ...currentCall, - participants: {...currentCall.participants}, + sessions: {...currentCall.sessions}, voiceOn, }; - delete nextCurrentCall.participants[userId]; + delete nextCurrentCall.sessions[sessionId]; // If they were screensharing, remove that. - if (nextCurrentCall.screenOn === userId) { + if (nextCurrentCall.screenOn === sessionId) { nextCurrentCall.screenOn = ''; } @@ -407,18 +409,18 @@ export const callEnded = (serverUrl: string, channelId: string) => { // currentCall is set to null by the disconnect. }; -export const setUserMuted = (serverUrl: string, channelId: string, userId: string, muted: boolean) => { +export const setUserMuted = (serverUrl: string, channelId: string, sessionId: string, muted: boolean) => { const callsState = getCallsState(serverUrl); - if (!callsState.calls[channelId] || !callsState.calls[channelId].participants[userId]) { + if (!callsState.calls[channelId] || !callsState.calls[channelId].sessions[sessionId]) { return; } - const nextUser = {...callsState.calls[channelId].participants[userId], muted}; + const nextUser = {...callsState.calls[channelId].sessions[sessionId], muted}; const nextCall = { ...callsState.calls[channelId], - participants: {...callsState.calls[channelId].participants}, + sessions: {...callsState.calls[channelId].sessions}, }; - nextCall.participants[userId] = nextUser; + nextCall.sessions[sessionId] = nextUser; const nextCalls = {...callsState.calls}; nextCalls[channelId] = nextCall; setCallsState(serverUrl, {...callsState, calls: nextCalls}); @@ -431,15 +433,15 @@ export const setUserMuted = (serverUrl: string, channelId: string, userId: strin const nextCurrentCall = { ...currentCall, - participants: { - ...currentCall.participants, - [userId]: {...currentCall.participants[userId], muted}, + sessions: { + ...currentCall.sessions, + [sessionId]: {...currentCall.sessions[sessionId], muted}, }, }; setCurrentCall(nextCurrentCall); }; -export const setUserVoiceOn = (channelId: string, userId: string, voiceOn: boolean) => { +export const setUserVoiceOn = (channelId: string, sessionId: string, voiceOn: boolean) => { const currentCall = getCurrentCall(); if (!currentCall || currentCall.channelId !== channelId) { return; @@ -447,9 +449,9 @@ export const setUserVoiceOn = (channelId: string, userId: string, voiceOn: boole const nextVoiceOn = {...currentCall.voiceOn}; if (voiceOn) { - nextVoiceOn[userId] = true; + nextVoiceOn[sessionId] = true; } else { - delete nextVoiceOn[userId]; + delete nextVoiceOn[sessionId]; } const nextCurrentCall = { @@ -459,18 +461,18 @@ export const setUserVoiceOn = (channelId: string, userId: string, voiceOn: boole setCurrentCall(nextCurrentCall); }; -export const setRaisedHand = (serverUrl: string, channelId: string, userId: string, timestamp: number) => { +export const setRaisedHand = (serverUrl: string, channelId: string, sessionId: string, timestamp: number) => { const callsState = getCallsState(serverUrl); - if (!callsState.calls[channelId] || !callsState.calls[channelId].participants[userId]) { + if (!callsState.calls[channelId] || !callsState.calls[channelId].sessions[sessionId]) { return; } - const nextUser = {...callsState.calls[channelId].participants[userId], raisedHand: timestamp}; + const nextUser = {...callsState.calls[channelId].sessions[sessionId], raisedHand: timestamp}; const nextCall = { ...callsState.calls[channelId], - participants: {...callsState.calls[channelId].participants}, + sessions: {...callsState.calls[channelId].sessions}, }; - nextCall.participants[userId] = nextUser; + nextCall.sessions[sessionId] = nextUser; const nextCalls = {...callsState.calls}; nextCalls[channelId] = nextCall; setCallsState(serverUrl, {...callsState, calls: nextCalls}); @@ -483,21 +485,21 @@ export const setRaisedHand = (serverUrl: string, channelId: string, userId: stri const nextCurrentCall = { ...currentCall, - participants: { - ...currentCall.participants, - [userId]: {...currentCall.participants[userId], raisedHand: timestamp}, + sessions: { + ...currentCall.sessions, + [sessionId]: {...currentCall.sessions[sessionId], raisedHand: timestamp}, }, }; setCurrentCall(nextCurrentCall); }; -export const setCallScreenOn = (serverUrl: string, channelId: string, userId: string) => { +export const setCallScreenOn = (serverUrl: string, channelId: string, sessionId: string) => { const callsState = getCallsState(serverUrl); - if (!callsState.calls[channelId] || !callsState.calls[channelId].participants[userId]) { + if (!callsState.calls[channelId] || !callsState.calls[channelId].sessions[sessionId]) { return; } - const nextCall = {...callsState.calls[channelId], screenOn: userId}; + const nextCall = {...callsState.calls[channelId], screenOn: sessionId}; const nextCalls = {...callsState.calls}; nextCalls[channelId] = nextCall; setCallsState(serverUrl, {...callsState, calls: nextCalls}); @@ -510,14 +512,14 @@ export const setCallScreenOn = (serverUrl: string, channelId: string, userId: st const nextCurrentCall = { ...currentCall, - screenOn: userId, + screenOn: sessionId, }; setCurrentCall(nextCurrentCall); }; -export const setCallScreenOff = (serverUrl: string, channelId: string) => { +export const setCallScreenOff = (serverUrl: string, channelId: string, sessionId: string) => { const callsState = getCallsState(serverUrl); - if (!callsState.calls[channelId]) { + if (!callsState.calls[channelId] || callsState.calls[channelId].screenOn !== sessionId) { return; } @@ -634,16 +636,16 @@ export const userReacted = (serverUrl: string, channelId: string, reaction: User } // Update the participant. - const nextParticipants = {...currentCall.participants}; - if (nextParticipants[reaction.user_id]) { - const nextUser = {...nextParticipants[reaction.user_id], reaction}; - nextParticipants[reaction.user_id] = nextUser; + const nextSessions = {...currentCall.sessions}; + if (nextSessions[reaction.session_id]) { + const nextUser = {...nextSessions[reaction.session_id], reaction}; + nextSessions[reaction.session_id] = nextUser; } const nextCurrentCall: CurrentCall = { ...currentCall, reactionStream: newReactionStream, - participants: nextParticipants, + sessions: nextSessions, }; setCurrentCall(nextCurrentCall); @@ -661,17 +663,17 @@ const userReactionTimeout = (serverUrl: string, channelId: string, reaction: Use // Remove the reaction only if it was the last time that emoji was used. const newReactions = currentCall.reactionStream.filter((e) => e.latestTimestamp !== reaction.timestamp); - const nextParticipants = {...currentCall.participants}; - if (nextParticipants[reaction.user_id] && nextParticipants[reaction.user_id].reaction?.timestamp === reaction.timestamp) { - const nextUser = {...nextParticipants[reaction.user_id]}; + const nextSessions = {...currentCall.sessions}; + if (nextSessions[reaction.session_id] && nextSessions[reaction.session_id].reaction?.timestamp === reaction.timestamp) { + const nextUser = {...nextSessions[reaction.session_id]}; delete nextUser.reaction; - nextParticipants[reaction.user_id] = nextUser; + nextSessions[reaction.session_id] = nextUser; } const nextCurrentCall: CurrentCall = { ...currentCall, reactionStream: newReactions, - participants: nextParticipants, + sessions: nextSessions, }; setCurrentCall(nextCurrentCall); }; diff --git a/app/products/calls/types/calls.ts b/app/products/calls/types/calls.ts index 963945fdd..c2bd051d0 100644 --- a/app/products/calls/types/calls.ts +++ b/app/products/calls/types/calls.ts @@ -50,7 +50,7 @@ export const DefaultIncomingCalls: IncomingCalls = { export type Call = { id: string; - participants: Dictionary; + sessions: Dictionary; channelId: string; startTime: number; screenOn: string; @@ -63,7 +63,7 @@ export type Call = { export const DefaultCall: Call = { id: '', - participants: {}, + sessions: {}, channelId: '', startTime: 0, screenOn: '', @@ -85,6 +85,7 @@ export type CurrentCall = Call & { connected: boolean; serverUrl: string; myUserId: string; + mySessionId: string; screenShareURL: string; speakerphoneOn: boolean; audioDeviceInfo: AudioDeviceInfo; @@ -100,6 +101,7 @@ export const DefaultCurrentCall: CurrentCall = { connected: false, serverUrl: '', myUserId: '', + mySessionId: '', screenShareURL: '', speakerphoneOn: false, audioDeviceInfo: {availableAudioDeviceList: [], selectedAudioDevice: AudioDevice.None}, @@ -110,8 +112,9 @@ export const DefaultCurrentCall: CurrentCall = { callQualityAlertDismissed: 0, }; -export type CallParticipant = { - id: string; +export type CallSession = { + sessionId: string; + userId: string; muted: boolean; raisedHand: number; userModel?: UserModel; @@ -134,11 +137,13 @@ export type CallsConnection = { export type CallsConfigState = CallsConfig & { AllowEnableCalls: boolean; pluginEnabled: boolean; + version: CallsVersion; last_retrieved_at: number; } export const DefaultCallsConfig: CallsConfigState = { pluginEnabled: false, + version: {}, ICEServers: [], // deprecated ICEServersConfigs: [], AllowEnableCalls: false, @@ -152,6 +157,7 @@ export const DefaultCallsConfig: CallsConfigState = { AllowScreenSharing: true, EnableSimulcast: false, EnableRinging: false, + EnableTranscriptions: false, }; export type ApiResp = { @@ -182,3 +188,8 @@ export type AudioDeviceInfo = { availableAudioDeviceList: AudioDevice[]; selectedAudioDevice: AudioDevice; }; + +export type CallsVersion = { + version?: string; + build?: string; +}; diff --git a/app/products/calls/utils.ts b/app/products/calls/utils.ts index 63ed92580..bcfd80a77 100644 --- a/app/products/calls/utils.ts +++ b/app/products/calls/utils.ts @@ -5,27 +5,31 @@ import {makeCallsBaseAndBadgeRGB, rgbToCSS} from '@mattermost/calls'; import {Alert} from 'react-native'; import {Calls, Post} from '@constants'; +import {NOTIFICATION_SUB_TYPE} from '@constants/push_notification'; import {isMinimumServerVersion} from '@utils/helpers'; import {displayUsername} from '@utils/user'; -import type {CallParticipant, CallsTheme} from '@calls/types/calls'; +import type {CallSession, CallsTheme, CallsVersion} from '@calls/types/calls'; import type {CallsConfig} from '@mattermost/calls/lib/types'; import type PostModel from '@typings/database/models/servers/post'; +import type UserModel from '@typings/database/models/servers/user'; import type {IntlShape} from 'react-intl'; import type {RTCIceServer} from 'react-native-webrtc'; -export function sortParticipants(locale: string, teammateNameDisplay: string, participants?: Dictionary, presenterID?: string): CallParticipant[] { - if (!participants) { +const callsMessageRegex = /^\u200b.* is inviting you to a call$/; + +export function sortSessions(locale: string, teammateNameDisplay: string, sessions?: Dictionary, presenterID?: string): CallSession[] { + if (!sessions) { return []; } - const users = Object.values(participants); + const sessns = Object.values(sessions); - return users.sort(sortByName(locale, teammateNameDisplay)).sort(sortByState(presenterID)); + return sessns.sort(sortByName(locale, teammateNameDisplay)).sort(sortByState(presenterID)); } const sortByName = (locale: string, teammateNameDisplay: string) => { - return (a: CallParticipant, b: CallParticipant) => { + return (a: CallSession, b: CallSession) => { const nameA = displayUsername(a.userModel, locale, teammateNameDisplay); const nameB = displayUsername(b.userModel, locale, teammateNameDisplay); return nameA.localeCompare(nameB); @@ -33,10 +37,10 @@ const sortByName = (locale: string, teammateNameDisplay: string) => { }; const sortByState = (presenterID?: string) => { - return (a: CallParticipant, b: CallParticipant) => { - if (a.id === presenterID) { + return (a: CallSession, b: CallSession) => { + if (a.sessionId === presenterID) { return -1; - } else if (b.id === presenterID) { + } else if (b.sessionId === presenterID) { return 1; } @@ -58,13 +62,13 @@ const sortByState = (presenterID?: string) => { }; }; -export function getHandsRaised(participants: Dictionary) { - return Object.values(participants).filter((p) => p.raisedHand); +export function getHandsRaised(sessions: Dictionary) { + return Object.values(sessions).filter((s) => s.raisedHand); } -export function getHandsRaisedNames(participants: CallParticipant[], currentUserId: string, locale: string, teammateNameDisplay: string, intl: IntlShape) { - return participants.sort((a, b) => a.raisedHand - b.raisedHand).map((p) => { - if (p.id === currentUserId) { +export function getHandsRaisedNames(sessions: CallSession[], sessionId: string, locale: string, teammateNameDisplay: string, intl: IntlShape) { + return sessions.sort((a, b) => a.raisedHand - b.raisedHand).map((p) => { + if (p.sessionId === sessionId) { return intl.formatMessage({id: 'mobile.calls_you_2', defaultMessage: 'You'}); } return displayUsername(p.userModel, locale, teammateNameDisplay); @@ -84,6 +88,15 @@ export function isSupportedServerCalls(serverVersion?: string) { return false; } +export function isMultiSessionSupported(callsVersion: CallsVersion) { + return isMinimumServerVersion( + callsVersion.version, + Calls.MultiSessionCallsVersion.MAJOR_VERSION, + Calls.MultiSessionCallsVersion.MIN_VERSION, + Calls.MultiSessionCallsVersion.PATCH_VERSION, + ); +} + export function isCallsCustomMessage(post: PostModel | Post): boolean { return Boolean(post.type && post.type === Post.POST_TYPES.CUSTOM_CALLS); } @@ -150,3 +163,41 @@ export function makeCallsTheme(theme: Theme): CallsTheme { return newTheme; } + +interface HasUserId { + userId: string; +} + +export function userIds(hasUserId: T[]): string[] { + const ids: string[] = []; + const seen: Record = {}; + for (const p of hasUserId) { + if (!seen[p.userId]) { + ids.push(p.userId); + seen[p.userId] = true; + } + } + return ids; +} + +export function fillUserModels(sessions: Dictionary, models: UserModel[]) { + const idToModel = models.reduce((accum, cur) => { + accum[cur.id] = cur; + return accum; + }, {} as Dictionary); + const next = {...sessions}; + for (const participant of Object.values(next)) { + participant.userModel = idToModel[participant.userId]; + } + return sessions; +} + +export function isCallsStartedMessage(payload?: NotificationData) { + if (payload?.sub_type === NOTIFICATION_SUB_TYPE.CALLS) { + return true; + } + + // MM-55506 - Remove once we can assume MM servers will be >= 9.3.0, mobile will be >= 2.11.0, + // calls will be >= 0.21.0, and push proxy will be >= 5.27.0 + return (payload?.message === 'You\'ve been invited to a call' || callsMessageRegex.test(payload?.message || '')); +} diff --git a/app/queries/servers/categories.ts b/app/queries/servers/categories.ts index c3bbc778f..5d7a1deec 100644 --- a/app/queries/servers/categories.ts +++ b/app/queries/servers/categories.ts @@ -34,6 +34,10 @@ export const queryCategoriesByTeamIds = (database: Database, teamIds: string[]) return database.get(CATEGORY).query(Q.where('team_id', Q.oneOf(teamIds))); }; +export const queryCategoryChannelsByChannelId = (database: Database, channelId: string) => { + return database.get(CATEGORY_CHANNEL).query(Q.where('channel_id', Q.eq(channelId))); +}; + export async function prepareCategoriesAndCategoriesChannels(operator: ServerDataOperator, categories: CategoryWithChannels[], prune = false) { try { const {database} = operator; @@ -45,20 +49,34 @@ export async function prepareCategoriesAndCategoriesChannels(operator: ServerDat const models = await Promise.all(modelPromises); const flattenedModels = models.flat(); + const teamIdToChannelIds = new Map>(); + categories.forEach((category) => { + const value = teamIdToChannelIds.get(category.team_id) || new Set(); + category.channel_ids.forEach(value.add, value); + teamIdToChannelIds.set(category.team_id, value); + }); + if (prune && categories.length) { const remoteCategoryIds = new Set(categories.map((cat) => cat.id)); // If the passed categories have more than one team, we want to update across teams const teamIds = pluckUnique('team_id')(categories) as string[]; const localCategories = await queryCategoriesByTeamIds(database, teamIds).fetch(); - const customCategories = localCategories.filter((c) => c.type === 'custom'); - for await (const custom of customCategories) { - if (!remoteCategoryIds.has(custom.id)) { - const categoryChannels = await custom.categoryChannels.fetch(); - for (const cc of categoryChannels) { + + for await (const localCategory of localCategories) { + const localCategoryChannels = await localCategory.categoryChannels.fetch(); + + if (remoteCategoryIds.has(localCategory.id)) { + for (const localCC of localCategoryChannels) { + if (!teamIdToChannelIds.get(localCategory.teamId)?.has(localCC.channelId)) { + flattenedModels.push(localCC.prepareDestroyPermanently()); + } + } + } else { + for (const cc of localCategoryChannels) { flattenedModels.push(cc.prepareDestroyPermanently()); } - flattenedModels.push(custom.prepareDestroyPermanently()); + flattenedModels.push(localCategory.prepareDestroyPermanently()); } } } diff --git a/app/queries/servers/system.ts b/app/queries/servers/system.ts index 7e7babc35..a369343cc 100644 --- a/app/queries/servers/system.ts +++ b/app/queries/servers/system.ts @@ -5,10 +5,11 @@ import {Database, Q} from '@nozbe/watermelondb'; import {of as of$, Observable, combineLatest} from 'rxjs'; import {switchMap, distinctUntilChanged} from 'rxjs/operators'; -import {Preferences} from '@constants'; +import {Preferences, License} from '@constants'; import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; import {PUSH_PROXY_STATUS_UNKNOWN} from '@constants/push_proxy'; import {isMinimumServerVersion} from '@utils/helpers'; +import {logError} from '@utils/log'; import type ServerDataOperator from '@database/operator/server_data_operator'; import type ConfigModel from '@typings/database/models/servers/config'; @@ -434,6 +435,22 @@ export async function setCurrentChannelId(operator: ServerDataOperator, channelI } } +export async function setCurrentTeamId(operator: ServerDataOperator, teamId: string) { + try { + const models = await prepareCommonSystemValues(operator, { + currentTeamId: teamId, + }); + if (models) { + await operator.batchRecords(models, 'setCurrentTeamId'); + } + + return {currentTeamId: teamId}; + } catch (error) { + logError(error); + return {error}; + } +} + export async function setCurrentTeamAndChannelId(operator: ServerDataOperator, teamId?: string, channelId?: string) { try { const models = await prepareCommonSystemValues(operator, { @@ -543,3 +560,60 @@ export const observeLastServerVersionCheck = (database: Database) => { switchMap((model) => of$(parseInt(model.value, 10))), ); }; + +export const observeIfHighlightWithoutNotificationHasLicense = (database: Database) => { + const license = observeLicense(database); + + const isCloudStarterFree = checkIsCloudStarterFree(license); + const isStarterSKULicense = checkIsStarterSKULicense(license); + const isSelfHostedStarter = observeIsSelfHosterStarter(database); + const isEnterpriseReady = observeConfigBooleanValue(database, 'BuildEnterpriseReady', false); + + return combineLatest([isCloudStarterFree, isStarterSKULicense, isSelfHostedStarter, isEnterpriseReady]).pipe( + switchMap(([isCSF, isSSL, isSHS, isEnt]) => { + // It should have enterprise build AND not have a starter license of any kind + const highlightWithoutNotificationHasLicense = isEnt && !(isCSF || isSSL || isSHS); + + return of$(highlightWithoutNotificationHasLicense); + }), + ); +}; + +function checkIsCloudStarterFree(license: Observable) { + return license.pipe( + switchMap((l) => { + const isCloud = l?.Cloud === 'true'; + const isStarterSKU = l?.SkuShortName === License.SKU_SHORT_NAME.Starter; + + return of$(isCloud && isStarterSKU); + }), + distinctUntilChanged(), + ); +} + +function checkIsStarterSKULicense(license: Observable) { + return license.pipe( + switchMap((l) => { + const isLicensed = l?.IsLicensed === 'true'; + const isSelfHostedStarterProduct = l?.SelfHostedProducts === License.SelfHostedProducts.STARTER; + + return of$(isLicensed && isSelfHostedStarterProduct); + }), + distinctUntilChanged(), + ); +} + +const observeIsSelfHosterStarter = (database: Database) => { + const license = observeLicense(database); + const isEnterpriseReady = observeConfigBooleanValue(database, 'BuildEnterpriseReady', false); + + return combineLatest([license, isEnterpriseReady]).pipe( + switchMap(([lic, isEnt]) => { + const isLicensed = lic?.IsLicensed === 'true'; + const isSelfHostedStarter = isEnt && !isLicensed; + + return of$(isSelfHostedStarter); + }), + ); +}; + diff --git a/app/screens/browse_channels/index.ts b/app/screens/browse_channels/index.ts index e0b0ab9d6..6e4d89ea2 100644 --- a/app/screens/browse_channels/index.ts +++ b/app/screens/browse_channels/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/screens/channel/channel_post_list/index.ts b/app/screens/channel/channel_post_list/index.ts index c135087a0..3713128c8 100644 --- a/app/screens/channel/channel_post_list/index.ts +++ b/app/screens/channel/channel_post_list/index.ts @@ -2,8 +2,7 @@ // See LICENSE.txt for license information. import {Q} from '@nozbe/watermelondb'; -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import React from 'react'; import {combineLatest, of as of$} from 'rxjs'; import {switchMap, distinctUntilChanged} from 'rxjs/operators'; diff --git a/app/screens/channel/channel_post_list/intro/direct_channel/group/index.ts b/app/screens/channel/channel_post_list/intro/direct_channel/group/index.ts index dcb4f124a..b4636d440 100644 --- a/app/screens/channel/channel_post_list/intro/direct_channel/group/index.ts +++ b/app/screens/channel/channel_post_list/intro/direct_channel/group/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {queryUsersById} from '@queries/servers/user'; diff --git a/app/screens/channel/channel_post_list/intro/direct_channel/index.ts b/app/screens/channel/channel_post_list/intro/direct_channel/index.ts index 52910a7d4..923a211d4 100644 --- a/app/screens/channel/channel_post_list/intro/direct_channel/index.ts +++ b/app/screens/channel/channel_post_list/intro/direct_channel/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/screens/channel/channel_post_list/intro/direct_channel/member/index.ts b/app/screens/channel/channel_post_list/intro/direct_channel/member/index.ts index 9927c73b0..5a60086b3 100644 --- a/app/screens/channel/channel_post_list/intro/direct_channel/member/index.ts +++ b/app/screens/channel/channel_post_list/intro/direct_channel/member/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {observeUser} from '@queries/servers/user'; diff --git a/app/screens/channel/channel_post_list/intro/index.ts b/app/screens/channel/channel_post_list/intro/index.ts index cf19d4686..25559b6b8 100644 --- a/app/screens/channel/channel_post_list/intro/index.ts +++ b/app/screens/channel/channel_post_list/intro/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {combineLatest} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/screens/channel/channel_post_list/intro/intro.tsx b/app/screens/channel/channel_post_list/intro/intro.tsx index fff727a89..a628bf91e 100644 --- a/app/screens/channel/channel_post_list/intro/intro.tsx +++ b/app/screens/channel/channel_post_list/intro/intro.tsx @@ -29,11 +29,6 @@ const styles = StyleSheet.create({ marginVertical: 12, paddingTop: PADDING_TOP, overflow: 'hidden', - ...Platform.select({ - android: { - scaleY: -1, - }, - }), }, }); diff --git a/app/screens/channel/channel_post_list/intro/public_or_private_channel/index.ts b/app/screens/channel/channel_post_list/intro/public_or_private_channel/index.ts index 79715c3e1..306bffecb 100644 --- a/app/screens/channel/channel_post_list/intro/public_or_private_channel/index.ts +++ b/app/screens/channel/channel_post_list/intro/public_or_private_channel/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {combineLatest, of as of$} from 'rxjs'; import {map} from 'rxjs/operators'; diff --git a/app/screens/channel/header/index.ts b/app/screens/channel/header/index.ts index 7c0de9c59..e59210e96 100644 --- a/app/screens/channel/header/index.ts +++ b/app/screens/channel/header/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import React from 'react'; import {of as of$} from 'rxjs'; import {combineLatestWith, switchMap} from 'rxjs/operators'; diff --git a/app/screens/channel/index.tsx b/app/screens/channel/index.tsx index 8c7c20419..094659b1b 100644 --- a/app/screens/channel/index.tsx +++ b/app/screens/channel/index.tsx @@ -1,17 +1,10 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; -import {combineLatest, distinctUntilChanged, of as of$, switchMap} from 'rxjs'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; +import {of as of$, switchMap} from 'rxjs'; -import {observeIsCallsEnabledInChannel} from '@calls/observers'; -import { - observeCallsState, - observeChannelsWithCalls, - observeCurrentCall, - observeIncomingCalls, -} from '@calls/state'; +import {observeCallStateInChannel, observeIsCallsEnabledInChannel} from '@calls/observers'; import {Preferences} from '@constants'; import {withServerUrl} from '@context/server'; import {observeCurrentChannel} from '@queries/servers/channel'; @@ -29,37 +22,6 @@ type EnhanceProps = WithDatabaseArgs & { const enhanced = withObservables([], ({database, serverUrl}: EnhanceProps) => { const channelId = observeCurrentChannelId(database); - - const isCallInCurrentChannel = combineLatest([channelId, observeChannelsWithCalls(serverUrl)]).pipe( - switchMap(([id, calls]) => of$(Boolean(calls[id]))), - distinctUntilChanged(), - ); - const currentCall = observeCurrentCall(); - const ccChannelId = currentCall.pipe( - switchMap((call) => of$(call?.channelId)), - distinctUntilChanged(), - ); - const isInACall = currentCall.pipe( - switchMap((call) => of$(Boolean(call?.connected))), - distinctUntilChanged(), - ); - const dismissed = combineLatest([channelId, observeCallsState(serverUrl)]).pipe( - switchMap(([id, state]) => of$(Boolean(state.calls[id]?.dismissed[state.myUserId]))), - distinctUntilChanged(), - ); - const isInCurrentChannelCall = combineLatest([channelId, ccChannelId]).pipe( - switchMap(([id, ccId]) => of$(id === ccId)), - distinctUntilChanged(), - ); - const showJoinCallBanner = combineLatest([isCallInCurrentChannel, dismissed, isInCurrentChannelCall]).pipe( - switchMap(([isCall, dism, inCurrCall]) => of$(Boolean(isCall && !dism && !inCurrCall))), - distinctUntilChanged(), - ); - const showIncomingCalls = observeIncomingCalls().pipe( - switchMap((ics) => of$(ics.incomingCalls.length > 0)), - distinctUntilChanged(), - ); - const dismissedGMasDMNotice = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.SYSTEM_NOTICE, Preferences.NOTICES.GM_AS_DM).observe(); const channelType = observeCurrentChannel(database).pipe(switchMap((c) => of$(c?.type))); const currentUserId = observeCurrentUserId(database); @@ -67,9 +29,7 @@ const enhanced = withObservables([], ({database, serverUrl}: EnhanceProps) => { return { channelId, - showJoinCallBanner, - isInACall, - showIncomingCalls, + ...observeCallStateInChannel(serverUrl, database, channelId), isCallsEnabledInChannel: observeIsCallsEnabledInChannel(database, serverUrl, channelId), dismissedGMasDMNotice, channelType, diff --git a/app/screens/channel_add_members/index.ts b/app/screens/channel_add_members/index.ts index 3e4ebfb4a..6feb51595 100644 --- a/app/screens/channel_add_members/index.ts +++ b/app/screens/channel_add_members/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {Tutorial} from '@constants'; import {observeTutorialWatched} from '@queries/app/global'; diff --git a/app/screens/channel_files/index.ts b/app/screens/channel_files/index.ts index 9979852e5..699673082 100644 --- a/app/screens/channel_files/index.ts +++ b/app/screens/channel_files/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {observeChannel} from '@queries/servers/channel'; import {observeCanDownloadFiles, observeConfigBooleanValue} from '@queries/servers/system'; diff --git a/app/screens/channel_info/app_bindings/index.tsx b/app/screens/channel_info/app_bindings/index.tsx index b73e8e246..4f65ac066 100644 --- a/app/screens/channel_info/app_bindings/index.tsx +++ b/app/screens/channel_info/app_bindings/index.tsx @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import React, {useCallback, useMemo} from 'react'; import {postEphemeralCallResponseForChannel} from '@actions/remote/apps'; diff --git a/app/screens/channel_info/channel_info.tsx b/app/screens/channel_info/channel_info.tsx index 8298427b9..5a63ac29c 100644 --- a/app/screens/channel_info/channel_info.tsx +++ b/app/screens/channel_info/channel_info.tsx @@ -7,6 +7,8 @@ import {type Edge, SafeAreaView} from 'react-native-safe-area-context'; import ChannelInfoEnableCalls from '@calls/components/channel_info_enable_calls'; import ChannelActions from '@components/channel_actions'; +import ConvertToChannelLabel from '@components/channel_actions/convert_to_channel/convert_to_channel_label'; +import {General} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; @@ -32,6 +34,8 @@ type Props = { canManageMembers: boolean; isCRTEnabled: boolean; canManageSettings: boolean; + isGuestUser: boolean; + isConvertGMFeatureAvailable: boolean; } const edges: Edge[] = ['bottom', 'left', 'right']; @@ -61,6 +65,8 @@ const ChannelInfo = ({ isCallsEnabledInChannel, canManageMembers, canManageSettings, + isGuestUser, + isConvertGMFeatureAvailable, }: Props) => { const theme = useTheme(); const serverUrl = useServerUrl(); @@ -77,6 +83,8 @@ const ChannelInfo = ({ useNavButtonPressed(closeButtonId, componentId, onPressed, [onPressed]); useAndroidHardwareBackHandler(componentId, onPressed); + const convertGMOptionAvailable = isConvertGMFeatureAvailable && type === General.GM_CHANNEL && !isGuestUser; + return ( + {convertGMOptionAvailable && + <> + + + + } {canEnableDisableCalls && <> { distinctUntilChanged(), ); + const isGuestUser = currentUser.pipe( + switchMap((u) => (u ? of$(u.isGuest) : of$(false))), + distinctUntilChanged(), + ); + + const isConvertGMFeatureAvailable = observeConfigValue(database, 'Version').pipe( + switchMap((version) => of$(isMinimumServerVersion(version || '', 9, 1))), + ); + return { type, canEnableDisableCalls, @@ -120,6 +128,8 @@ const enhanced = withObservables([], ({serverUrl, database}: Props) => { canManageMembers, isCRTEnabled: observeIsCRTEnabled(database), canManageSettings, + isGuestUser, + isConvertGMFeatureAvailable, }; }); diff --git a/app/screens/channel_info/options/add_members/index.ts b/app/screens/channel_info/options/add_members/index.ts index 23b62cdad..c00314dea 100644 --- a/app/screens/channel_info/options/add_members/index.ts +++ b/app/screens/channel_info/options/add_members/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/screens/channel_info/options/auto_follow_threads/index.ts b/app/screens/channel_info/options/auto_follow_threads/index.ts index f2a7a993a..20c19c9bb 100644 --- a/app/screens/channel_info/options/auto_follow_threads/index.ts +++ b/app/screens/channel_info/options/auto_follow_threads/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/screens/channel_info/options/channel_files/index.ts b/app/screens/channel_info/options/channel_files/index.ts index bc819c9e1..03e29f7c6 100644 --- a/app/screens/channel_info/options/channel_files/index.ts +++ b/app/screens/channel_info/options/channel_files/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/screens/channel_info/options/ignore_mentions/index.ts b/app/screens/channel_info/options/ignore_mentions/index.ts index d5d7448e7..afc897a60 100644 --- a/app/screens/channel_info/options/ignore_mentions/index.ts +++ b/app/screens/channel_info/options/ignore_mentions/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {combineLatestWith, switchMap} from 'rxjs/operators'; diff --git a/app/screens/channel_info/options/members/index.ts b/app/screens/channel_info/options/members/index.ts index 5f8724b5a..cf85273f9 100644 --- a/app/screens/channel_info/options/members/index.ts +++ b/app/screens/channel_info/options/members/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/screens/channel_info/options/notification_preference/index.ts b/app/screens/channel_info/options/notification_preference/index.ts index 2e63f35bc..fb148a9cb 100644 --- a/app/screens/channel_info/options/notification_preference/index.ts +++ b/app/screens/channel_info/options/notification_preference/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/screens/channel_info/options/pinned_messages/index.ts b/app/screens/channel_info/options/pinned_messages/index.ts index f5eaf0772..762be00f0 100644 --- a/app/screens/channel_info/options/pinned_messages/index.ts +++ b/app/screens/channel_info/options/pinned_messages/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/screens/channel_info/title/direct_message/index.ts b/app/screens/channel_info/title/direct_message/index.ts index e9abe8dea..13a3c9166 100644 --- a/app/screens/channel_info/title/direct_message/index.ts +++ b/app/screens/channel_info/title/direct_message/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {combineLatestWith, switchMap} from 'rxjs/operators'; diff --git a/app/screens/channel_info/title/group_message/avatars/index.ts b/app/screens/channel_info/title/group_message/avatars/index.ts index bce9edb23..201b400e5 100644 --- a/app/screens/channel_info/title/group_message/avatars/index.ts +++ b/app/screens/channel_info/title/group_message/avatars/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {queryUsersById} from '@queries/servers/user'; diff --git a/app/screens/channel_info/title/group_message/index.ts b/app/screens/channel_info/title/group_message/index.ts index bf5083235..f94928615 100644 --- a/app/screens/channel_info/title/group_message/index.ts +++ b/app/screens/channel_info/title/group_message/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/screens/channel_info/title/index.ts b/app/screens/channel_info/title/index.ts index 4fe752bb5..0dafac8ff 100644 --- a/app/screens/channel_info/title/index.ts +++ b/app/screens/channel_info/title/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/screens/channel_info/title/public_private/index.ts b/app/screens/channel_info/title/public_private/index.ts index b9448cae5..19b5fb567 100644 --- a/app/screens/channel_info/title/public_private/index.ts +++ b/app/screens/channel_info/title/public_private/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; diff --git a/app/screens/channel_notification_preferences/index.ts b/app/screens/channel_notification_preferences/index.ts index 12116400e..601972304 100644 --- a/app/screens/channel_notification_preferences/index.ts +++ b/app/screens/channel_notification_preferences/index.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap, combineLatestWith} from 'rxjs/operators'; diff --git a/app/screens/convert_gm_to_channel/channel_name_input.tsx b/app/screens/convert_gm_to_channel/channel_name_input.tsx new file mode 100644 index 000000000..fb159819d --- /dev/null +++ b/app/screens/convert_gm_to_channel/channel_name_input.tsx @@ -0,0 +1,44 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl} from 'react-intl'; + +import FloatingTextInput from '@components/floating_text_input_label'; +import {Channel} from '@constants'; +import {useTheme} from '@context/theme'; +import {getKeyboardAppearanceFromTheme} from '@utils/theme'; + +type Props = { + error?: string; + onChange: (text: string) => void; +} + +export const ChannelNameInput = ({error, onChange}: Props) => { + const {formatMessage} = useIntl(); + const theme = useTheme(); + + const labelDisplayName = formatMessage({id: 'channel_modal.name', defaultMessage: 'Name'}); + const placeholder = formatMessage({id: 'channel_modal.name', defaultMessage: 'Channel Name'}); + + return ( + + ); +}; diff --git a/app/screens/convert_gm_to_channel/convert_gm_to_channel.tsx b/app/screens/convert_gm_to_channel/convert_gm_to_channel.tsx new file mode 100644 index 000000000..e1eb4f933 --- /dev/null +++ b/app/screens/convert_gm_to_channel/convert_gm_to_channel.tsx @@ -0,0 +1,155 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; + +import {fetchChannelMemberships, fetchGroupMessageMembersCommonTeams} from '@actions/remote/channel'; +import {PER_PAGE_DEFAULT} from '@client/rest/constants'; +import Loading from '@components/loading'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import ConvertGMToChannelForm from './convert_gm_to_channel_form'; + +type Props = { + channelId: string; + currentUserId?: string; +} + +const loadingIndicatorTimeout = 1200; + +const matchUserProfiles = (users: UserProfile[], members: ChannelMembership[], currentUserId: string) => { + // Gotta make sure we use profiles that are in members. + // See comment in fetchChannelMemberships for more details. + + const usersById: {[id: string]: UserProfile} = {}; + users.forEach((profile) => { + if (profile.id !== currentUserId) { + usersById[profile.id] = profile; + } + }); + + const filteredUsers: UserProfile[] = []; + members.forEach((member) => { + if (usersById[member.user_id]) { + filteredUsers.push(usersById[member.user_id]); + } + }); + + return filteredUsers; +}; + +const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { + return { + loadingContainer: { + justifyContent: 'center', + alignItems: 'center', + flex: 1, + gap: 24, + }, + text: { + color: changeOpacity(theme.centerChannelColor, 0.56), + ...typography('Body', 300, 'SemiBold'), + }, + container: { + paddingVertical: 24, + paddingHorizontal: 20, + display: 'flex', + flexDirection: 'column', + gap: 24, + }, + }; +}); + +const ConvertGMToChannel = ({ + channelId, + currentUserId, +}: Props) => { + const theme = useTheme(); + const styles = getStyleFromTheme(theme); + + const {formatMessage} = useIntl(); + + const [loadingAnimationTimeout, setLoadingAnimationTimeout] = useState(false); + const [commonTeamsFetched, setCommonTeamsFetched] = useState(false); + const [channelMembersFetched, setChannelMembersFetched] = useState(false); + const [commonTeams, setCommonTeams] = useState([]); + const [profiles, setProfiles] = useState([]); + + const serverUrl = useServerUrl(); + const mounted = useRef(false); + + const loadingAnimationTimeoutRef = useRef(); + + useEffect(() => { + loadingAnimationTimeoutRef.current = setTimeout(() => setLoadingAnimationTimeout(true), loadingIndicatorTimeout); + async function work() { + const {teams} = await fetchGroupMessageMembersCommonTeams(serverUrl, channelId); + if (!teams || !mounted.current) { + return; + } + setCommonTeams(teams); + setCommonTeamsFetched(true); + } + + work(); + + return () => { + clearTimeout(loadingAnimationTimeoutRef.current); + }; + }, []); + + useEffect(() => { + mounted.current = true; + + return () => { + mounted.current = false; + }; + }, []); + + useEffect(() => { + if (!currentUserId) { + return; + } + + const options: GetUsersOptions = {sort: 'admin', active: true, per_page: PER_PAGE_DEFAULT}; + fetchChannelMemberships(serverUrl, channelId, options, true).then(({users, members}) => { + if (!mounted.current) { + return; + } + + if (users.length) { + setProfiles(matchUserProfiles(users, members, currentUserId)); + } + + setChannelMembersFetched(true); + }); + }, [serverUrl, channelId, currentUserId]); + + const showLoader = !loadingAnimationTimeout || !commonTeamsFetched || !channelMembersFetched; + + if (showLoader) { + return ( + + ); + } + + return ( + + ); +}; + +export default ConvertGMToChannel; diff --git a/app/screens/convert_gm_to_channel/convert_gm_to_channel_form/convert_gm_to_channel_form.tsx b/app/screens/convert_gm_to_channel/convert_gm_to_channel_form/convert_gm_to_channel_form.tsx new file mode 100644 index 000000000..a38f0392e --- /dev/null +++ b/app/screens/convert_gm_to_channel/convert_gm_to_channel_form/convert_gm_to_channel_form.tsx @@ -0,0 +1,180 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {Text, View} from 'react-native'; + +import {convertGroupMessageToPrivateChannel, switchToChannelById} from '@actions/remote/channel'; +import Button from '@components/button'; +import Loading from '@components/loading'; +import {ServerErrors} from '@constants'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {isErrorWithMessage, isServerError} from '@utils/errors'; +import {logError} from '@utils/log'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {displayUsername} from '@utils/user'; + +import {ChannelNameInput} from '../channel_name_input'; +import MessageBox from '../message_box/message_box'; +import {TeamSelector} from '../team_selector'; + +import {NoCommonTeamForm} from './no_common_teams_form'; + +const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + paddingVertical: 24, + paddingHorizontal: 20, + display: 'flex', + flexDirection: 'column', + gap: 24, + }, + errorMessage: { + color: theme.dndIndicator, + }, + loadingContainerStyle: { + marginRight: 10, + padding: 0, + top: -2, + }, + }; +}); + +type Props = { + channelId: string; + commonTeams: Team[]; + profiles: UserProfile[]; + locale?: string; + teammateNameDisplay?: string; +} + +export const ConvertGMToChannelForm = ({ + channelId, + commonTeams, + profiles, + locale, + teammateNameDisplay, +}: Props) => { + const theme = useTheme(); + const styles = getStyleFromTheme(theme); + const serverUrl = useServerUrl(); + const {formatList, formatMessage} = useIntl(); + + const [selectedTeam, setSelectedTeam] = useState(commonTeams[0]); + const [newChannelName, setNewChannelName] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [channelNameErrorMessage, setChannelNameErrorMessage] = useState(''); + const [conversionInProgress, setConversionInProgress] = useState(false); + + const userDisplayNames = useMemo(() => profiles.map((profile) => displayUsername(profile, locale, teammateNameDisplay)), [profiles, teammateNameDisplay, locale]); + const submitButtonEnabled = !conversionInProgress && selectedTeam && newChannelName.trim(); + + const handleOnPress = useCallback(preventDoubleTap(async () => { + if (!submitButtonEnabled) { + return; + } + + setConversionInProgress(true); + + const {updatedChannel, error} = await convertGroupMessageToPrivateChannel(serverUrl, channelId, selectedTeam.id, newChannelName); + if (error) { + if (isServerError(error) && error.server_error_id === ServerErrors.DUPLICATE_CHANNEL_NAME && isErrorWithMessage(error)) { + setChannelNameErrorMessage(error.message); + } else if (isErrorWithMessage(error)) { + setErrorMessage(error.message); + } else { + setErrorMessage(formatMessage({id: 'channel_info.convert_gm_to_channel.conversion_error', defaultMessage: 'Something went wrong. Failed to convert Group Message to Private Channel.'})); + } + + setConversionInProgress(false); + return; + } + + if (!updatedChannel) { + logError('No updated channel received from server when converting GM to private channel'); + setErrorMessage(formatMessage({id: 'channel_info.convert_gm_to_channel.conversion_error', defaultMessage: 'Something went wrong. Failed to convert Group Message to Private Channel.'})); + setConversionInProgress(false); + return; + } + + setErrorMessage(''); + switchToChannelById(serverUrl, updatedChannel.id, selectedTeam.id); + setConversionInProgress(false); + }), [selectedTeam, newChannelName, submitButtonEnabled]); + + if (commonTeams.length === 0) { + return ( + + ); + } + + const messageBoxHeader = formatMessage({ + id: 'channel_info.convert_gm_to_channel.warning.header', + defaultMessage: 'Conversation history will be visible to any channel members', + }); + + const textConvert = formatMessage({ + id: 'channel_info.convert_gm_to_channel.button_text', + defaultMessage: 'Convert to Private Channel', + }); + + const textConverting = formatMessage({ + id: 'channel_info.convert_gm_to_channel.button_text_converting', + defaultMessage: 'Converting...', + }); + + const confirmButtonText = conversionInProgress ? textConverting : textConvert; + const defaultUserDisplayNames = formatMessage({id: 'channel_info.convert_gm_to_channel.warning.body.yourself', defaultMessage: 'yourself'}); + const memberNames = profiles.length > 0 ? formatList(userDisplayNames) : defaultUserDisplayNames; + const messageBoxBody = formatMessage({ + id: 'channel_info.convert_gm_to_channel.warning.bodyXXXX', + defaultMessage: 'You are about to convert the Group Message with {memberNames} to a Channel. This cannot be undone.', + }, { + memberNames, + }); + + const buttonIcon = conversionInProgress ? ( + + ) : null; + + return ( + + + { + commonTeams.length > 1 && + + } + +