Compare commits

...

16 Commits

Author SHA1 Message Date
Elias Nahum
ff73d946ae Bump app build number to 458 (#7147) 2023-02-17 16:19:49 +02:00
Carrie Warner (Mattermost)
b9f15afa81 Updated onboarding text (#7138)
* Updated onboarding text

https://mattermost.atlassian.net/browse/MM-46606

* Updated string in code
2023-02-16 15:55:44 +02:00
Elias Nahum
a11d3c6d2a Use config to set SAML and OpenId Button text (#7145) 2023-02-16 14:12:43 +02:00
Elias Nahum
3f2769aa0f allow scrolling in the login screen when keyboard is opened (#7144) 2023-02-16 14:00:26 +02:00
Elias Nahum
a78e6ff673 Do not dismiss keyboard when app is brought to the foreground (#7143) 2023-02-16 13:58:26 +02:00
Sudhanva-Nadiger
4416a61a77 fix: reset password bug (#7135)
* fix: reset password bug

* add suggested changes
2023-02-16 12:09:12 +02:00
Elias Nahum
77b0851213 Update Dependencies (#7140)
* upgrade android dependencies

* upgrade iOS dependencies

* Enable network plugin in flipper for Android

* update JS dependencies
2023-02-16 11:20:31 +02:00
Elias Nahum
86fff5c728 Sanitize sqlite like queries and allow non-latin characters (#7141) 2023-02-16 11:18:05 +02:00
Elias Nahum
78190cbc47 Observe on is_unread in the channel list unread section (#7133) 2023-02-15 17:08:43 +02:00
Elias Nahum
6def5d9610 Process notifications when the app is in the background and other perf improvements (#7129) 2023-02-15 17:08:19 +02:00
Elias Nahum
153c2f7c8d iOS refactor push notifications to store data or send to JS for processing (#7128) 2023-02-15 17:07:54 +02:00
Elias Nahum
ee13679f38 Android refactor push notifications to store data or send to JS for processing (#7127)
* Android refactor push notifications to store data or send to JS for processing

* remove blank line

* rename variable
2023-02-15 17:07:27 +02:00
Elias Nahum
e99d63d498 Do not show archived dms in the category list (#7130) 2023-02-15 15:21:37 +02:00
Elias Nahum
23cbf82353 Use LRU to cache the Avatar shown in push notifications (#7124)
* iOS switch from file cache to memory cache and use last_picture_update to update the avatar if needed

* Android switch from file cache to memory cache and use last_picture_update to update the avatar if needed, split function to multiple files and catch potential exceptions
2023-02-15 11:19:31 +02:00
Elias Nahum
ab5084ce48 use the correct skin tone when selecting an emoji from the picker (#7125) 2023-02-15 11:19:14 +02:00
Elias Nahum
9d6558e6e8 Fix open channel of existing DM (#7126) 2023-02-15 11:19:00 +02:00
149 changed files with 7349 additions and 5552 deletions

View File

@@ -1,8 +1,6 @@
apply plugin: "com.android.application"
apply plugin: "com.facebook.react"
apply plugin: 'kotlin-android'
import com.android.build.OutputFile
/**
* This is the configuration block to customize your React Native Android app.
@@ -112,7 +110,7 @@ android {
applicationId "com.mattermost.rnbeta"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 457
versionCode 458
versionName "2.1.0"
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
@@ -173,10 +171,10 @@ android {
// For each separate APK per architecture, set a unique version code as described here:
// http://tools.android.com/tech-docs/new-build-system/user-guide/apk-splits
def versionCodes = ["armeabi-v7a":1, "x86":2, "arm64-v8a": 3, "x86_64": 4]
def abi = output.getFilter(OutputFile.ABI)
def abi = output.filters[0]
if (abi != null) { // null for the universal-debug, universal-release variants
output.versionCodeOverride =
versionCodes.get(abi) * 2000000 + defaultConfig.versionCode
versionCodes.get(abi.identifier) * 2000000 + defaultConfig.versionCode
}
}
}
@@ -192,7 +190,7 @@ 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.0.0")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}")
debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") {
@@ -206,18 +204,19 @@ dependencies {
implementation jscFlavor
}
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4'
implementation 'io.reactivex.rxjava3:rxjava:3.1.6'
implementation 'io.reactivex.rxjava3:rxandroid:3.0.2'
implementation 'androidx.window:window-rxjava3:1.0.0'
implementation 'androidx.window:window:1.0.0'
implementation 'androidx.appcompat:appcompat:1.4.1'
implementation 'com.google.android.material:material:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.8.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation "com.google.firebase:firebase-messaging:$firebaseVersion"
androidTestImplementation('com.wix:detox:+')
implementation project(':reactnativenotifications')
implementation project(':watermelondb')
implementation project(':watermelondb-jsi')
}
@@ -225,16 +224,16 @@ configurations.all {
resolutionStrategy {
eachDependency { DependencyResolveDetails details ->
if (details.requested.name == 'play-services-base') {
details.useTarget group: details.requested.group, name: details.requested.name, version: '15.0.1'
details.useTarget group: details.requested.group, name: details.requested.name, version: '18.1.0'
}
if (details.requested.name == 'play-services-tasks') {
details.useTarget group: details.requested.group, name: details.requested.name, version: '15.0.1'
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: '15.0.1'
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: '15.0.1'
details.useTarget group: details.requested.group, name: details.requested.name, version: '18.1.0'
}
if (details.requested.name == 'okhttp') {
details.useTarget group: details.requested.group, name: details.requested.name, version: '4.10.0'

View File

@@ -22,7 +22,7 @@ import com.facebook.react.ReactInstanceEventListener;
import com.facebook.react.ReactInstanceManager;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.modules.network.NetworkingModule;
import okhttp3.OkHttpClient;
import com.mattermost.networkclient.RCTOkHttpClientFactory;
/**
* Class responsible of loading Flipper inside your React Native application. This is the debug
@@ -37,13 +37,9 @@ public class ReactNativeFlipper {
client.addPlugin(new SharedPreferencesFlipperPlugin(context));
client.addPlugin(CrashReporterPlugin.getInstance());
NetworkFlipperPlugin networkFlipperPlugin = new NetworkFlipperPlugin();
RCTOkHttpClientFactory.Companion.setFlipperPlugin(networkFlipperPlugin);
NetworkingModule.setCustomClientBuilder(
new NetworkingModule.CustomClientBuilder() {
@Override
public void apply(OkHttpClient.Builder builder) {
builder.addNetworkInterceptor(new FlipperOkhttpInterceptor(networkFlipperPlugin));
}
});
builder -> builder.addNetworkInterceptor(new FlipperOkhttpInterceptor(networkFlipperPlugin)));
client.addPlugin(networkFlipperPlugin);
client.start();
// Fresco Plugin needs to ensure that ImagePipelineFactory is initialized
@@ -56,12 +52,7 @@ public class ReactNativeFlipper {
public void onReactContextInitialized(ReactContext reactContext) {
reactInstanceManager.removeReactInstanceEventListener(this);
reactContext.runOnNativeModulesQueueThread(
new Runnable() {
@Override
public void run() {
client.addPlugin(new FrescoFlipperPlugin());
}
});
() -> client.addPlugin(new FrescoFlipperPlugin()));
}
});
} else {

View File

@@ -5,6 +5,7 @@ import android.util.LruCache
class BitmapCache {
private var memoryCache: LruCache<String, Bitmap>
private var keysCache: LruCache<String, String>
init {
val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
@@ -14,15 +15,35 @@ class BitmapCache {
return bitmap.byteCount / 1024
}
}
keysCache = LruCache<String, String>(50)
}
fun getBitmapFromMemCache(key: String): Bitmap? {
fun bitmap(userId: String, updatedAt: Double, serverUrl: String): Bitmap? {
val key = "$serverUrl-$userId-$updatedAt"
return memoryCache.get(key)
}
fun addBitmapToMemoryCache(key: String, bitmap: Bitmap) {
if (getBitmapFromMemCache(key) == null) {
memoryCache.put(key, bitmap)
fun insertBitmap(bitmap: Bitmap?, userId: String, updatedAt: Double, serverUrl: String) {
if (bitmap == null) {
removeBitmap(userId, serverUrl)
}
val key = "$serverUrl-$userId-$updatedAt"
val cachedKey = "$serverUrl-$userId"
keysCache.put(cachedKey, key)
memoryCache.put(key, bitmap)
}
fun removeBitmap(userId: String, serverUrl: String) {
val cachedKey = "$serverUrl-$userId"
val key = keysCache.get(cachedKey)
if (key != null) {
memoryCache.remove(key)
keysCache.remove(cachedKey)
}
}
fun removeAllBitmaps() {
memoryCache.evictAll()
keysCache.evictAll()
}
}

View File

@@ -34,7 +34,7 @@ public class Credentials {
String service = map.getString("service");
assert service != null;
if (service.isEmpty()) {
String[] credentials = token[0].split(",[ ]*");
String[] credentials = token[0].split(", *");
if (credentials.length == 2) {
token[0] = credentials[0];
}

View File

@@ -21,6 +21,7 @@ import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.app.Person;
@@ -28,6 +29,7 @@ import androidx.core.app.RemoteInput;
import androidx.core.graphics.drawable.IconCompat;
import com.mattermost.rnbeta.*;
import com.nozbe.watermelondb.Database;
import java.io.IOException;
import java.util.Date;
@@ -37,6 +39,9 @@ import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import static com.mattermost.helpers.database_extension.GeneralKt.getDatabaseForServer;
import static com.mattermost.helpers.database_extension.UserKt.getLastPictureUpdate;
public class CustomPushNotificationHelper {
public static final String CHANNEL_HIGH_IMPORTANCE_ID = "channel_01";
public static final String CHANNEL_MIN_IMPORTANCE_ID = "channel_02";
@@ -55,7 +60,7 @@ public class CustomPushNotificationHelper {
private static final BitmapCache bitmapCache = new BitmapCache();
private static void addMessagingStyleMessages(NotificationCompat.MessagingStyle messagingStyle, String conversationTitle, Bundle bundle) {
private static void addMessagingStyleMessages(Context context, NotificationCompat.MessagingStyle messagingStyle, String conversationTitle, Bundle bundle) {
String message = bundle.getString("message", bundle.getString("body"));
String senderId = bundle.getString("sender_id");
String serverUrl = bundle.getString("server_url");
@@ -77,7 +82,7 @@ public class CustomPushNotificationHelper {
if (serverUrl != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) {
try {
Bitmap avatar = userAvatar(serverUrl, senderId, urlOverride);
Bitmap avatar = userAvatar(context, serverUrl, senderId, urlOverride);
if (avatar != null) {
sender.setIcon(IconCompat.createWithBitmap(avatar));
}
@@ -123,6 +128,7 @@ public class CustomPushNotificationHelper {
notification.addExtras(userInfoBundle);
}
@SuppressLint("UnspecifiedImmutableFlag")
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");
@@ -179,8 +185,8 @@ public class CustomPushNotificationHelper {
String groupId = is_crt_enabled && !android.text.TextUtils.isEmpty(rootId) ? rootId : channelId;
addNotificationExtras(notification, bundle);
setNotificationIcons(notification, bundle);
setNotificationMessagingStyle(notification, bundle);
setNotificationIcons(context, notification, bundle);
setNotificationMessagingStyle(context, notification, bundle);
setNotificationGroup(notification, groupId, createSummary);
setNotificationBadgeType(notification);
@@ -256,7 +262,7 @@ public class CustomPushNotificationHelper {
return title;
}
private static NotificationCompat.MessagingStyle getMessagingStyle(Bundle bundle) {
private static NotificationCompat.MessagingStyle getMessagingStyle(Context context, Bundle bundle) {
NotificationCompat.MessagingStyle messagingStyle;
final String senderId = "me";
final String serverUrl = bundle.getString("server_url");
@@ -269,7 +275,7 @@ public class CustomPushNotificationHelper {
if (serverUrl != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) {
try {
Bitmap avatar = userAvatar(serverUrl, "me", urlOverride);
Bitmap avatar = userAvatar(context, serverUrl, "me", urlOverride);
if (avatar != null) {
sender.setIcon(IconCompat.createWithBitmap(avatar));
}
@@ -282,7 +288,7 @@ public class CustomPushNotificationHelper {
String conversationTitle = getConversationTitle(bundle);
setMessagingStyleConversationTitle(messagingStyle, conversationTitle, bundle);
addMessagingStyleMessages(messagingStyle, conversationTitle, bundle);
addMessagingStyleMessages(context, messagingStyle, conversationTitle, bundle);
return messagingStyle;
}
@@ -364,8 +370,8 @@ public class CustomPushNotificationHelper {
notification.setDeleteIntent(deleteIntent);
}
private static void setNotificationMessagingStyle(NotificationCompat.Builder notification, Bundle bundle) {
NotificationCompat.MessagingStyle messagingStyle = getMessagingStyle(bundle);
private static void setNotificationMessagingStyle(Context context, NotificationCompat.Builder notification, Bundle bundle) {
NotificationCompat.MessagingStyle messagingStyle = getMessagingStyle(context, bundle);
notification.setStyle(messagingStyle);
}
@@ -378,7 +384,7 @@ public class CustomPushNotificationHelper {
}
}
private static void setNotificationIcons(NotificationCompat.Builder notification, Bundle bundle) {
private static void setNotificationIcons(Context context, NotificationCompat.Builder notification, Bundle bundle) {
String channelName = getConversationTitle(bundle);
String senderName = bundle.getString("sender_name");
String serverUrl = bundle.getString("server_url");
@@ -389,7 +395,7 @@ public class CustomPushNotificationHelper {
if (serverUrl != null && channelName.equals(senderName)) {
try {
String senderId = bundle.getString("sender_id");
Bitmap avatar = userAvatar(serverUrl, senderId, urlOverride);
Bitmap avatar = userAvatar(context, serverUrl, senderId, urlOverride);
if (avatar != null) {
notification.setLargeIcon(avatar);
}
@@ -399,19 +405,33 @@ public class CustomPushNotificationHelper {
}
}
private static Bitmap userAvatar(final String serverUrl, final String userId, final String urlOverride) throws IOException {
private static Bitmap userAvatar(final Context context, @NonNull final String serverUrl, final String userId, final String urlOverride) throws IOException {
try {
Response response;
Double lastUpdateAt = 0.0;
if (!TextUtils.isEmpty(urlOverride)) {
Request request = new Request.Builder().url(urlOverride).build();
Log.i("ReactNative", String.format("Fetch override profile image %s", urlOverride));
response = client.newCall(request).execute();
} else {
Bitmap cached = bitmapCache.getBitmapFromMemCache(userId);
DatabaseHelper dbHelper = DatabaseHelper.Companion.getInstance();
if (dbHelper != null) {
Database db = getDatabaseForServer(dbHelper, context, serverUrl);
if (db != null) {
lastUpdateAt = getLastPictureUpdate(db, userId);
if (lastUpdateAt == null) {
lastUpdateAt = 0.0;
}
db.close();
}
}
Bitmap cached = bitmapCache.bitmap(userId, lastUpdateAt, serverUrl);
if (cached != null) {
Bitmap bitmap = cached.copy(cached.getConfig(), false);
return getCircleBitmap(bitmap);
}
bitmapCache.removeBitmap(userId, serverUrl);
String url = String.format("api/v4/users/%s/image", userId);
Log.i("ReactNative", String.format("Fetch profile image %s", url));
response = Network.getSync(serverUrl, url, null);
@@ -422,7 +442,7 @@ public class CustomPushNotificationHelper {
byte[] bytes = Objects.requireNonNull(response.body()).bytes();
Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
if (TextUtils.isEmpty(urlOverride) && !TextUtils.isEmpty(userId)) {
bitmapCache.addBitmapToMemoryCache(userId, bitmap.copy(bitmap.getConfig(), false));
bitmapCache.insertBitmap(bitmap.copy(bitmap.getConfig(), false), userId, lastUpdateAt, serverUrl);
}
return getCircleBitmap(bitmap);
}

View File

@@ -2,32 +2,26 @@ package com.mattermost.helpers
import android.content.Context
import android.net.Uri
import android.text.TextUtils
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.NoSuchKeyException
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.nozbe.watermelondb.Database
import com.nozbe.watermelondb.mapCursor
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import java.lang.Exception
import java.util.*
import org.json.JSONArray
import org.json.JSONObject
class DatabaseHelper {
private var defaultDatabase: Database? = null
var defaultDatabase: Database? = null
val onlyServerUrl: String?
get() {
try {
val query = "SELECT url FROM Servers WHERE last_active_at != 0 AND identifier != ''"
val cursor = defaultDatabase!!.rawQuery(query)
if (cursor.count == 1) {
cursor.moveToFirst()
val url = cursor.getString(0)
cursor.close()
return url
defaultDatabase!!.rawQuery(query).use { cursor ->
if (cursor.count == 1) {
cursor.moveToFirst()
return cursor.getString(0)
}
}
} catch (e: Exception) {
e.printStackTrace()
@@ -42,640 +36,13 @@ class DatabaseHelper {
}
}
fun getServerUrlForIdentifier(identifier: String): String? {
try {
val args: Array<Any?> = arrayOf(identifier)
val query = "SELECT url FROM Servers WHERE identifier=?"
val cursor = defaultDatabase!!.rawQuery(query, args)
if (cursor.count == 1) {
cursor.moveToFirst()
val url = cursor.getString(0)
cursor.close()
return url
}
} catch (e: Exception) {
e.printStackTrace()
// let it fall to return null
}
return null
}
fun find(db: Database, tableName: String, id: String?): ReadableMap? {
val args: Array<Any?> = arrayOf(id)
try {
db.rawQuery("select * from $tableName where id == ? limit 1", args).use { cursor ->
if (cursor.count <= 0) {
return null
}
val resultMap = Arguments.createMap()
cursor.moveToFirst()
resultMap.mapCursor(cursor)
return resultMap
}
} catch (e: Exception) {
e.printStackTrace()
return null
}
}
fun getDatabaseForServer(context: Context?, serverUrl: String): Database? {
try {
val args: Array<Any?> = arrayOf(serverUrl)
val query = "SELECT db_path FROM Servers WHERE url=?"
val cursor = defaultDatabase!!.rawQuery(query, args)
if (cursor.count == 1) {
cursor.moveToFirst()
val databasePath = cursor.getString(0)
cursor.close()
return Database(databasePath, context!!)
}
} catch (e: Exception) {
e.printStackTrace()
// let it fall to return null
}
return null
}
fun queryIds(db: Database, tableName: String, ids: Array<String>): List<String> {
val list: MutableList<String> = ArrayList()
val args = TextUtils.join(",", Arrays.stream(ids).map { "?" }.toArray())
try {
db.rawQuery("select distinct id from $tableName where id IN ($args)", ids as Array<Any?>).use { cursor ->
if (cursor.count > 0) {
while (cursor.moveToNext()) {
val index = cursor.getColumnIndex("id")
if (index >= 0) {
list.add(cursor.getString(index))
}
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return list
}
fun queryByColumn(db: Database, tableName: String, columnName: String, values: Array<Any?>): List<String> {
val list: MutableList<String> = ArrayList()
val args = TextUtils.join(",", Arrays.stream(values).map { "?" }.toArray())
try {
db.rawQuery("select distinct $columnName from $tableName where $columnName IN ($args)", values).use { cursor ->
if (cursor.count > 0) {
while (cursor.moveToNext()) {
val index = cursor.getColumnIndex(columnName)
if (index >= 0) {
list.add(cursor.getString(index))
}
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return list
}
fun queryCurrentUserId(db: Database): String? {
val result = find(db, "System", "currentUserId")!!
return result.getString("value")
}
private fun queryLastPostCreateAt(db: Database?, channelId: String): Double? {
if (db != null) {
val postsInChannelQuery = "SELECT earliest, latest FROM PostsInChannel WHERE channel_id=? ORDER BY latest DESC LIMIT 1"
val cursor1 = db.rawQuery(postsInChannelQuery, arrayOf(channelId))
if (cursor1.count == 1) {
cursor1.moveToFirst()
val earliest = cursor1.getDouble(0)
val latest = cursor1.getDouble(1)
cursor1.close()
val postQuery = "SELECT create_at FROM POST WHERE channel_id= ? AND delete_at=0 AND create_at BETWEEN ? AND ? ORDER BY create_at DESC"
val cursor2 = db.rawQuery(postQuery, arrayOf(channelId, earliest, latest))
if (cursor2.count >= 60) {
cursor2.moveToFirst()
val createAt = cursor2.getDouble(0)
cursor2.close()
return createAt
}
}
}
return null
}
fun queryPostSinceForChannel(db: Database?, channelId: String): Double? {
try {
if (db != null) {
val postsInChannelQuery = "SELECT last_fetched_at FROM MyChannel WHERE id=? LIMIT 1"
val cursor1 = db.rawQuery(postsInChannelQuery, arrayOf(channelId))
if (cursor1.count == 1) {
cursor1.moveToFirst()
val lastFetchedAt = cursor1.getDouble(0)
cursor1.close()
if (lastFetchedAt == 0.0) {
return queryLastPostCreateAt(db, channelId)
}
return lastFetchedAt
}
}
} catch (e: Exception) {
e.printStackTrace()
// let it fall to return null
}
return null
}
fun handlePosts(db: Database, postsData: ReadableMap?, channelId: String, receivingThreads: Boolean) {
// Posts, PostInChannel, PostInThread, Reactions, Files, CustomEmojis, Users
if (postsData != null) {
val ordered = postsData.getArray("order")?.toArrayList()
val posts = ReadableMapUtils.toJSONObject(postsData.getMap("posts")).toMap()
val previousPostId = postsData.getString("prev_post_id")
val postsInThread = hashMapOf<String, List<JSONObject>>()
val postList = posts.toList()
var earliest = 0.0
var latest = 0.0
var lastFetchedAt = 0.0
if (ordered != null && posts.isNotEmpty()) {
val firstId = ordered.first()
val lastId = ordered.last()
lastFetchedAt = postList.fold(0.0) { acc, next ->
val post = next.second as Map<*, *>
val createAt = post["create_at"] as Double
val updateAt = post["update_at"] as Double
val deleteAt = post["delete_at"] as Double
val value = maxOf(createAt, updateAt, deleteAt)
maxOf(value, acc)
}
var prevPostId = ""
val sortedPosts = postList.sortedBy { (_, value) ->
((value as Map<*, *>)["create_at"] as Double)
}
sortedPosts.forEachIndexed { index, it ->
val key = it.first
if (it.second != null) {
val post = it.second as MutableMap<String, Any?>
if (index == 0) {
post.putIfAbsent("prev_post_id", previousPostId)
} else if (prevPostId.isNotEmpty()) {
post.putIfAbsent("prev_post_id", prevPostId)
}
if (lastId == key) {
earliest = post["create_at"] as Double
}
if (firstId == key) {
latest = post["create_at"] as Double
}
val jsonPost = JSONObject(post)
val rootId = post["root_id"] as? String
if (!rootId.isNullOrEmpty()) {
var thread = postsInThread[rootId]?.toMutableList()
if (thread == null) {
thread = mutableListOf()
}
thread.add(jsonPost)
postsInThread[rootId] = thread.toList()
}
if (find(db, "Post", key) == null) {
insertPost(db, jsonPost)
} else {
updatePost(db, jsonPost)
}
if (ordered.contains(key)) {
prevPostId = key
}
}
}
}
if (!receivingThreads) {
handlePostsInChannel(db, channelId, earliest, latest)
updateMyChannelLastFetchedAt(db, channelId, lastFetchedAt)
}
handlePostsInThread(db, postsInThread)
}
}
fun handleThreads(db: Database, threads: ReadableArray) {
for (i in 0 until threads.size()) {
val thread = threads.getMap(i)
val threadId = thread.getString("id")
// Insert/Update the thread
val existingRecord = find(db, "Thread", threadId)
if (existingRecord == null) {
insertThread(db, thread)
} else {
updateThread(db, thread, existingRecord)
}
// Delete existing and insert thread participants
val participants = thread.getArray("participants")
if (participants != null) {
db.execute("delete from ThreadParticipant where thread_id = ?", arrayOf(threadId))
if (participants.size() > 0) {
insertThreadParticipants(db, threadId!!, participants)
}
}
}
}
fun handleUsers(db: Database, users: ReadableArray) {
for (i in 0 until users.size()) {
val user = users.getMap(i)
val roles = user.getString("roles") ?: ""
val isBot = try {
user.getBoolean("is_bot")
} catch (e: NoSuchKeyException) {
false
}
val lastPictureUpdate = try { user.getDouble("last_picture_update") } catch (e: NoSuchKeyException) { 0 }
db.execute(
"insert into User (id, auth_service, update_at, delete_at, email, first_name, is_bot, is_guest, " +
"last_name, last_picture_update, locale, nickname, position, roles, status, username, notify_props, " +
"props, timezone, _status) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'created')",
arrayOf(
user.getString("id"),
user.getString("auth_service"),
user.getDouble("update_at"),
user.getDouble("delete_at"),
user.getString("email"),
user.getString("first_name"),
isBot,
roles.contains("system_guest"),
user.getString("last_name"),
lastPictureUpdate,
user.getString("locale"),
user.getString("nickname"),
user.getString("position"),
roles,
"",
user.getString("username"),
"{}",
ReadableMapUtils.toJSONObject(user.getMap("props") ?: Arguments.createMap()).toString(),
ReadableMapUtils.toJSONObject(user.getMap("timezone") ?: Arguments.createMap()).toString(),
)
)
}
}
private fun setDefaultDatabase(context: Context) {
val databaseName = "app.db"
val databasePath = Uri.fromFile(context.filesDir).toString() + "/" + databaseName
defaultDatabase = Database(databasePath, context)
}
private fun insertPost(db: Database, post: JSONObject) {
var metadata: JSONObject?
var reactions: JSONArray? = null
var customEmojis: JSONArray? = null
var files: JSONArray? = null
try {
metadata = post.getJSONObject("metadata")
reactions = metadata.remove("reactions") as JSONArray?
customEmojis = metadata.remove("emojis") as JSONArray?
files = metadata.remove("files") as JSONArray?
} catch (e: Exception) {
// no metadata found
metadata = JSONObject()
}
db.execute(
"insert into Post " +
"(id, channel_id, create_at, delete_at, update_at, edit_at, is_pinned, message, metadata, original_id, pending_post_id, " +
"previous_post_id, root_id, type, user_id, props, _status)" +
" values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'created')",
arrayOf(
post.getString("id"),
post.getString("channel_id"),
post.getDouble("create_at"),
post.getDouble("delete_at"),
post.getDouble("update_at"),
post.getDouble("edit_at"),
post.getBoolean("is_pinned"),
post.getString("message"),
metadata.toString(),
post.getString("original_id"),
post.getString("pending_post_id"),
post.getString("prev_post_id"),
post.getString("root_id"),
post.getString("type"),
post.getString("user_id"),
post.getJSONObject("props").toString()
)
)
if (reactions != null && reactions.length() > 0) {
insertReactions(db, reactions)
}
if (customEmojis != null && customEmojis.length() > 0) {
insertCustomEmojis(db, customEmojis)
}
if (files != null && files.length() > 0) {
insertFiles(db, files)
}
}
private fun updatePost(db: Database, post: JSONObject) {
var metadata: JSONObject?
var reactions: JSONArray? = null
var customEmojis: JSONArray? = null
try {
metadata = post.getJSONObject("metadata")
reactions = metadata.remove("reactions") as JSONArray?
customEmojis = metadata.remove("emojis") as JSONArray?
metadata.remove("files")
} catch (e: Exception) {
// no metadata found
metadata = JSONObject()
}
db.execute(
"update Post SET channel_id = ?, create_at = ?, delete_at = ?, update_at =?, edit_at =?, " +
"is_pinned = ?, message = ?, metadata = ?, original_id = ?, pending_post_id = ?, previous_post_id = ?, " +
"root_id = ?, type = ?, user_id = ?, props = ?, _status = 'updated' " +
"where id = ?",
arrayOf(
post.getString("channel_id"),
post.getDouble("create_at"),
post.getDouble("delete_at"),
post.getDouble("update_at"),
post.getDouble("edit_at"),
post.getBoolean("is_pinned"),
post.getString("message"),
metadata.toString(),
post.getString("original_id"),
post.getString("pending_post_id"),
post.getString("prev_post_id"),
post.getString("root_id"),
post.getString("type"),
post.getString("user_id"),
post.getJSONObject("props").toString(),
post.getString("id"),
)
)
if (reactions != null && reactions.length() > 0) {
db.execute("delete from Reaction where post_id = ?", arrayOf(post.getString("id")))
insertReactions(db, reactions)
}
if (customEmojis != null && customEmojis.length() > 0) {
insertCustomEmojis(db, customEmojis)
}
}
private fun insertThread(db: Database, thread: ReadableMap) {
// These fields are not present when we extract threads from posts
val isFollowing = try { thread.getBoolean("is_following") } catch (e: NoSuchKeyException) { false }
val lastViewedAt = try { thread.getDouble("last_viewed_at") } catch (e: NoSuchKeyException) { 0 }
val unreadReplies = try { thread.getInt("unread_replies") } catch (e: NoSuchKeyException) { 0 }
val unreadMentions = try { thread.getInt("unread_mentions") } catch (e: NoSuchKeyException) { 0 }
val lastReplyAt = try { thread.getDouble("last_reply_at") } catch (e: NoSuchKeyException) { 0 }
val replyCount = try { thread.getInt("reply_count") } catch (e: NoSuchKeyException) { 0 }
db.execute(
"insert into Thread " +
"(id, last_reply_at, last_fetched_at, last_viewed_at, reply_count, is_following, unread_replies, unread_mentions, _status)" +
" values (?, ?, 0, ?, ?, ?, ?, ?, 'created')",
arrayOf(
thread.getString("id"),
lastReplyAt,
lastViewedAt,
replyCount,
isFollowing,
unreadReplies,
unreadMentions
)
)
}
private fun updateThread(db: Database, thread: ReadableMap, existingRecord: ReadableMap) {
// These fields are not present when we extract threads from posts
val isFollowing = try { thread.getBoolean("is_following") } catch (e: NoSuchKeyException) { existingRecord.getInt("is_following") == 1 }
val lastViewedAt = try { thread.getDouble("last_viewed_at") } catch (e: NoSuchKeyException) { existingRecord.getDouble("last_viewed_at") }
val unreadReplies = try { thread.getInt("unread_replies") } catch (e: NoSuchKeyException) { existingRecord.getInt("unread_replies") }
val unreadMentions = try { thread.getInt("unread_mentions") } catch (e: NoSuchKeyException) { existingRecord.getInt("unread_mentions") }
val lastReplyAt = try { thread.getDouble("last_reply_at") } catch (e: NoSuchKeyException) { 0 }
val replyCount = try { thread.getInt("reply_count") } catch (e: NoSuchKeyException) { 0 }
db.execute(
"update Thread SET last_reply_at = ?, last_viewed_at = ?, reply_count = ?, is_following = ?, unread_replies = ?, unread_mentions = ?, _status = 'updated' where id = ?",
arrayOf(
lastReplyAt,
lastViewedAt,
replyCount,
isFollowing,
unreadReplies,
unreadMentions,
thread.getString("id")
)
)
}
private fun insertThreadParticipants(db: Database, threadId: String, participants: ReadableArray) {
for (i in 0 until participants.size()) {
val participant = participants.getMap(i)
val id = RandomId.generate()
db.execute(
"insert into ThreadParticipant " +
"(id, thread_id, user_id, _status)" +
" values (?, ?, ?, 'created')",
arrayOf(
id,
threadId,
participant.getString("id")
)
)
}
}
private fun insertCustomEmojis(db: Database, customEmojis: JSONArray) {
for (i in 0 until customEmojis.length()) {
val emoji = customEmojis.getJSONObject(i)
if(find(db, "CustomEmoji", emoji.getString("id")) == null) {
db.execute(
"insert into CustomEmoji (id, name, _status) values (?, ?, 'created')",
arrayOf(
emoji.getString("id"),
emoji.getString("name"),
)
)
}
}
}
private fun insertFiles(db: Database, files: JSONArray) {
for (i in 0 until files.length()) {
val file = files.getJSONObject(i)
val miniPreview = try { file.getString("mini_preview") } catch (e: JSONException) { "" }
val height = try { file.getInt("height") } catch (e: JSONException) { 0 }
val width = try { file.getInt("width") } catch (e: JSONException) { 0 }
db.execute(
"insert into File (id, extension, height, image_thumbnail, local_path, mime_type, name, post_id, size, width, _status) " +
"values (?, ?, ?, ?, '', ?, ?, ?, ?, ?, 'created')",
arrayOf(
file.getString("id"),
file.getString("extension"),
height,
miniPreview,
file.getString("mime_type"),
file.getString("name"),
file.getString("post_id"),
file.getDouble("size"),
width
)
)
}
}
private fun insertReactions(db: Database, reactions: JSONArray) {
for (i in 0 until reactions.length()) {
val reaction = reactions.getJSONObject(i)
val id = RandomId.generate()
db.execute(
"insert into Reaction (id, create_at, emoji_name, post_id, user_id, _status) " +
"values (?, ?, ?, ?, ?, 'created')",
arrayOf(
id,
reaction.getDouble("create_at"),
reaction.getString("emoji_name"),
reaction.getString("post_id"),
reaction.getString("user_id")
)
)
}
}
private fun handlePostsInChannel(db: Database, channelId: String, earliest: Double, latest: Double) {
db.rawQuery("select id, channel_id, earliest, latest from PostsInChannel where channel_id = ?", arrayOf(channelId)).use { cursor ->
if (cursor.count == 0) {
// create new post in channel
insertPostInChannel(db, channelId, earliest, latest)
return
}
val resultArray = Arguments.createArray()
while (cursor.moveToNext()) {
val cursorMap = Arguments.createMap()
cursorMap.mapCursor(cursor)
resultArray.pushMap(cursorMap)
}
val chunk = findPostInChannel(resultArray, earliest, latest)
if (chunk != null) {
db.execute(
"update PostsInChannel set earliest = ?, latest = ?, _status = 'updated' where id = ?",
arrayOf(
minOf(earliest, chunk.getDouble("earliest")),
maxOf(latest, chunk.getDouble("latest")),
chunk.getString("id")
)
)
return
}
val newChunk = insertPostInChannel(db, channelId, earliest, latest)
mergePostsInChannel(db, resultArray, newChunk)
}
}
private fun updateMyChannelLastFetchedAt(db: Database, channelId: String, lastFetchedAt: Double) {
db.execute(
"UPDATE MyChannel SET last_fetched_at = ?, _status = 'updated' WHERE id = ?",
arrayOf(
lastFetchedAt,
channelId
)
)
}
private fun findPostInChannel(chunks: ReadableArray, earliest: Double, latest: Double): ReadableMap? {
for (i in 0 until chunks.size()) {
val chunk = chunks.getMap(i)
if (earliest >= chunk.getDouble("earliest") || latest <= chunk.getDouble("latest")) {
return chunk
}
}
return null
}
private fun insertPostInChannel(db: Database, channelId: String, earliest: Double, latest: Double): ReadableMap {
val id = RandomId.generate()
db.execute("insert into PostsInChannel (id, channel_id, earliest, latest, _status) values (?, ?, ?, ?, 'created')",
arrayOf(id, channelId, earliest, latest))
val map = Arguments.createMap()
map.putString("id", id)
map.putString("channel_id", channelId)
map.putDouble("earliest", earliest)
map.putDouble("latest", latest)
return map
}
private fun mergePostsInChannel(db: Database, existingChunks: ReadableArray, newChunk: ReadableMap) {
for (i in 0 until existingChunks.size()) {
val chunk = existingChunks.getMap(i)
if (newChunk.getDouble("earliest") <= chunk.getDouble("earliest") &&
newChunk.getDouble("latest") >= chunk.getDouble("latest")) {
db.execute("delete from PostsInChannel where id = ?", arrayOf(chunk.getString("id")))
break
}
}
}
private fun handlePostsInThread(db: Database, postsInThread: Map<String, List<JSONObject>>) {
postsInThread.forEach { (key, list) ->
val sorted = list.sortedBy { it.getDouble("create_at") }
val earliest = sorted.first().getDouble("create_at")
val latest = sorted.last().getDouble("create_at")
db.rawQuery("select * from PostsInThread where root_id = ? order by latest desc", arrayOf(key)).use { cursor ->
if (cursor.count > 0) {
cursor.moveToFirst()
val cursorMap = Arguments.createMap()
cursorMap.mapCursor(cursor)
db.execute(
"update PostsInThread set earliest = ?, latest = ?, _status = 'updated' where id = ?",
arrayOf(
minOf(earliest, cursorMap.getDouble("earliest")),
maxOf(latest, cursorMap.getDouble("latest")),
key
)
)
return
}
val id = RandomId.generate()
db.execute(
"insert into PostsInThread (id, root_id, earliest, latest, _status) " +
"values (?, ?, ?, ?, 'created')",
arrayOf(id, key, earliest, latest)
)
}
}
}
private fun JSONObject.toMap(): Map<String, *> = keys().asSequence().associateWith { it ->
internal fun JSONObject.toMap(): Map<String, Any?> = keys().asSequence().associateWith { it ->
when (val value = this[it])
{
is JSONArray ->
@@ -683,9 +50,15 @@ class DatabaseHelper {
val map = (0 until value.length()).associate { Pair(it.toString(), value[it]) }
JSONObject(map).toMap().values.toList()
}
is JSONObject -> value.toMap()
JSONObject.NULL -> null
else -> value
is JSONObject -> {
value.toMap()
}
JSONObject.NULL -> {
null
}
else -> {
value
}
}
}

View File

@@ -4,292 +4,151 @@ import android.content.Context
import android.os.Bundle
import android.util.Log
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.WritableNativeArray
import com.nozbe.watermelondb.Database
import java.io.IOException
import java.util.concurrent.Executors
import kotlin.coroutines.*
import com.mattermost.helpers.database_extension.*
import com.mattermost.helpers.push_notification.*
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class PushNotificationDataHelper(private val context: Context) {
private var scope = Executors.newSingleThreadExecutor()
fun fetchAndStoreDataForPushNotification(initialData: Bundle) {
scope.execute(Runnable {
runBlocking {
PushNotificationDataRunnable.start(context, initialData)
}
})
private var coroutineScope = CoroutineScope(Dispatchers.Default)
fun fetchAndStoreDataForPushNotification(initialData: Bundle, isReactInit: Boolean): Bundle? {
var result: Bundle? = null
val job = coroutineScope.launch(Dispatchers.Default) {
result = PushNotificationDataRunnable.start(context, initialData, isReactInit)
}
runBlocking {
job.join()
}
return result
}
}
class PushNotificationDataRunnable {
companion object {
private val specialMentions = listOf("all", "here", "channel")
internal val specialMentions = listOf("all", "here", "channel")
private val dbHelper = DatabaseHelper.instance!!
private val mutex = Mutex()
@Synchronized
suspend fun start(context: Context, initialData: Bundle) {
try {
val serverUrl: String = initialData.getString("server_url") ?: return
val channelId = initialData.getString("channel_id")
val rootId = initialData.getString("root_id")
val isCRTEnabled = initialData.getString("is_crt_enabled") == "true"
val db = DatabaseHelper.instance!!.getDatabaseForServer(context, serverUrl)
Log.i("ReactNative", "Start fetching notification data in server="+serverUrl+" for channel="+channelId)
suspend fun start(context: Context, initialData: Bundle, isReactInit: Boolean): Bundle? {
// for more info see: https://blog.danlew.net/2020/01/28/coroutines-and-java-synchronization-dont-mix/
mutex.withLock {
val serverUrl: String = initialData.getString("server_url") ?: return null
val db = dbHelper.getDatabaseForServer(context, serverUrl)
var result: Bundle? = null
if (db != null) {
var postData: ReadableMap?
var posts: ReadableMap? = null
var userIdsToLoad: ReadableArray? = null
var usernamesToLoad: ReadableArray? = null
try {
if (db != null) {
val teamId = initialData.getString("team_id")
val channelId = initialData.getString("channel_id")
val postId = initialData.getString("post_id")
val rootId = initialData.getString("root_id")
val isCRTEnabled = initialData.getString("is_crt_enabled") == "true"
var threads: ReadableArray? = null
var usersFromThreads: ReadableArray? = null
val receivingThreads = isCRTEnabled && !rootId.isNullOrEmpty()
Log.i("ReactNative", "Start fetching notification data in server=$serverUrl for channel=$channelId")
coroutineScope {
if (channelId != null) {
postData = fetchPosts(db, serverUrl, channelId, isCRTEnabled, rootId)
val receivingThreads = isCRTEnabled && !rootId.isNullOrEmpty()
val notificationData = Arguments.createMap()
posts = postData?.getMap("posts")
userIdsToLoad = postData?.getArray("userIdsToLoad")
usernamesToLoad = postData?.getArray("usernamesToLoad")
threads = postData?.getArray("threads")
usersFromThreads = postData?.getArray("usersFromThreads")
if (!teamId.isNullOrEmpty()) {
val res = fetchTeamIfNeeded(db, serverUrl, teamId)
res.first?.let { notificationData.putMap("team", it) }
res.second?.let { notificationData.putMap("myTeam", it) }
}
if (userIdsToLoad != null && userIdsToLoad!!.size() > 0) {
val users = fetchUsersById(serverUrl, userIdsToLoad!!)
userIdsToLoad = users?.getArray("data")
if (channelId != null && postId != null) {
val channelRes = fetchMyChannel(db, serverUrl, channelId, isCRTEnabled)
channelRes.first?.let { notificationData.putMap("channel", it) }
channelRes.second?.let { notificationData.putMap("myChannel", it) }
val loadedProfiles = channelRes.third
// Fetch categories if needed
if (!teamId.isNullOrEmpty() && notificationData.getMap("myTeam") != null) {
// should load all categories
val res = fetchMyTeamCategories(db, serverUrl, teamId)
res?.let { notificationData.putMap("categories", it) }
} else if (notificationData.getMap("channel") != null) {
// check if the channel is in the category for the team
val res = addToDefaultCategoryIfNeeded(db, notificationData.getMap("channel")!!)
res?.let { notificationData.putArray("categoryChannels", it) }
}
if (usernamesToLoad != null && usernamesToLoad!!.size() > 0) {
val users = fetchUsersByUsernames(serverUrl, usernamesToLoad!!)
usernamesToLoad = users?.getArray("data")
val postData = fetchPosts(db, serverUrl, channelId, isCRTEnabled, rootId, loadedProfiles)
postData?.getMap("posts")?.let { notificationData.putMap("posts", it) }
var notificationThread: ReadableMap? = null
if (isCRTEnabled && !rootId.isNullOrEmpty()) {
notificationThread = fetchThread(db, serverUrl, rootId, teamId)
}
getThreadList(notificationThread, postData?.getArray("threads"))?.let {
val threadsArray = Arguments.createArray()
for(item in it) {
threadsArray.pushMap(item)
}
notificationData.putArray("threads", threadsArray)
}
val userList = fetchNeededUsers(serverUrl, loadedProfiles, postData)
notificationData.putArray("users", ReadableArrayUtils.toWritableArray(userList.toArray()))
}
result = Arguments.toBundle(notificationData)
if (!isReactInit) {
dbHelper.saveToDatabase(db, notificationData, teamId, channelId, receivingThreads)
}
Log.i("ReactNative", "Done processing push notification=$serverUrl for channel=$channelId")
}
db.transaction {
if (posts != null && channelId != null) {
DatabaseHelper.instance!!.handlePosts(db, posts!!.getMap("data"), channelId, receivingThreads)
}
if (threads != null) {
DatabaseHelper.instance!!.handleThreads(db, threads!!)
}
if (userIdsToLoad != null && userIdsToLoad!!.size() > 0) {
DatabaseHelper.instance!!.handleUsers(db, userIdsToLoad!!)
}
if (usernamesToLoad != null && usernamesToLoad!!.size() > 0) {
DatabaseHelper.instance!!.handleUsers(db, usernamesToLoad!!)
}
if (usersFromThreads != null) {
DatabaseHelper.instance!!.handleUsers(db, usersFromThreads!!)
}
}
db.close()
Log.i("ReactNative", "Done processing push notification="+serverUrl+" for channel="+channelId)
} catch (e: Exception) {
e.printStackTrace()
} finally {
db?.close()
Log.i("ReactNative", "DONE fetching notification data")
}
} catch (e: Exception) {
e.printStackTrace()
return result
}
}
private suspend fun fetchPosts(db: Database, serverUrl: String, channelId: String, isCRTEnabled: Boolean, rootId: String?): ReadableMap? {
val regex = Regex("""\B@(([a-z0-9-._]*[a-z0-9_])[.-]*)""", setOf(RegexOption.IGNORE_CASE))
val since = DatabaseHelper.instance!!.queryPostSinceForChannel(db, channelId)
val currentUserId = DatabaseHelper.instance!!.queryCurrentUserId(db)?.removeSurrounding("\"")
val currentUser = DatabaseHelper.instance!!.find(db, "User", currentUserId)
val currentUsername = currentUser?.getString("username")
var additionalParams = ""
if (isCRTEnabled) {
additionalParams = "&collapsedThreads=true&collapsedThreadsExtended=true"
}
val receivingThreads = isCRTEnabled && !rootId.isNullOrEmpty()
val endpoint = if (receivingThreads) {
val queryParams = "?skipFetchThreads=false&perPage=60&fromCreatedAt=0&direction=up"
"/api/v4/posts/$rootId/thread$queryParams$additionalParams"
} else {
val queryParams = if (since == null) "?page=0&per_page=60" else "?since=${since.toLong()}"
"/api/v4/channels/$channelId/posts$queryParams$additionalParams"
}
val postsResponse = fetch(serverUrl, endpoint)
val results = Arguments.createMap()
if (postsResponse != null) {
val data = ReadableMapUtils.toMap(postsResponse)
results.putMap("posts", postsResponse)
val postsData = data["data"] as? Map<*, *>
if (postsData != null) {
val postsMap = postsData["posts"]
if (postsMap != null) {
val posts = ReadableMapUtils.toWritableMap(postsMap as? Map<String, Any>)
val iterator = posts.keySetIterator()
val userIds = mutableListOf<String>()
val usernames = mutableListOf<String>()
val threads = WritableNativeArray()
val threadParticipantUserIds = mutableListOf<String>() // Used to exclude the "userIds" present in the thread participants
val threadParticipantUsernames = mutableListOf<String>() // Used to exclude the "usernames" present in the thread participants
val threadParticipantUsers = HashMap<String, ReadableMap>() // All unique users from thread participants are stored here
while(iterator.hasNextKey()) {
val key = iterator.nextKey()
val post = posts.getMap(key)
val userId = post?.getString("user_id")
if (userId != null && userId != currentUserId && !userIds.contains(userId)) {
userIds.add(userId)
}
val message = post?.getString("message")
if (message != null) {
val matchResults = regex.findAll(message)
matchResults.iterator().forEach {
val username = it.value.removePrefix("@")
if (!usernames.contains(username) && currentUsername != username && !specialMentions.contains(username)) {
usernames.add(username)
}
}
}
if (isCRTEnabled) {
// Add root post as a thread
val threadId = post?.getString("root_id")
if (threadId.isNullOrEmpty()) {
threads.pushMap(post!!)
}
// Add participant userIds and usernames to exclude them from getting fetched again
val participants = post.getArray("participants")
if (participants != null) {
for (i in 0 until participants.size()) {
val participant = participants.getMap(i)
val participantId = participant.getString("id")
if (participantId != currentUserId && participantId != null) {
if (!threadParticipantUserIds.contains(participantId)) {
threadParticipantUserIds.add(participantId)
}
if (!threadParticipantUsers.containsKey(participantId)) {
threadParticipantUsers[participantId] = participant
}
}
val username = participant.getString("username")
if (username != null && username != currentUsername && !threadParticipantUsernames.contains(username)) {
threadParticipantUsernames.add(username)
}
}
}
}
}
val existingUserIds = DatabaseHelper.instance!!.queryIds(db, "User", userIds.toTypedArray())
val existingUsernames = DatabaseHelper.instance!!.queryByColumn(db, "User", "username", usernames.toTypedArray())
userIds.removeAll { it in existingUserIds }
usernames.removeAll { it in existingUsernames }
if (threadParticipantUserIds.size > 0) {
// Do not fetch users found in thread participants as we get the user's data in the posts response already
userIds.removeAll { it in threadParticipantUserIds }
usernames.removeAll { it in threadParticipantUsernames }
// Get users from thread participants
val existingThreadParticipantUserIds = DatabaseHelper.instance!!.queryIds(db, "User", threadParticipantUserIds.toTypedArray())
// Exclude the thread participants already present in the DB from getting inserted again
val usersFromThreads = WritableNativeArray()
threadParticipantUsers.forEach{ (userId, user) ->
if (!existingThreadParticipantUserIds.contains(userId)) {
usersFromThreads.pushMap(user)
}
}
if (usersFromThreads.size() > 0) {
results.putArray("usersFromThreads", usersFromThreads)
}
}
if (userIds.size > 0) {
results.putArray("userIdsToLoad", ReadableArrayUtils.toWritableArray(userIds.toTypedArray()))
}
if (usernames.size > 0) {
results.putArray("usernamesToLoad", ReadableArrayUtils.toWritableArray(usernames.toTypedArray()))
}
if (threads.size() > 0) {
results.putArray("threads", threads)
}
}
private fun getThreadList(notificationThread: ReadableMap?, threads: ReadableArray?): ArrayList<ReadableMap>? {
threads?.let {
val threadsArray = ArrayList<ReadableMap>()
val threadIds = ArrayList<String>()
notificationThread?.let { thread ->
thread.getString("id")?.let { it1 -> threadIds.add(it1) }
threadsArray.add(thread)
}
}
return results
}
private suspend fun fetchUsersById(serverUrl: String, userIds: ReadableArray): ReadableMap? {
val endpoint = "api/v4/users/ids"
val options = Arguments.createMap()
options.putArray("body", ReadableArrayUtils.toWritableArray(ReadableArrayUtils.toArray(userIds)))
return fetchWithPost(serverUrl, endpoint, options)
}
private suspend fun fetchUsersByUsernames(serverUrl: String, usernames: ReadableArray): ReadableMap? {
val endpoint = "api/v4/users/usernames"
val options = Arguments.createMap()
options.putArray("body", ReadableArrayUtils.toWritableArray(ReadableArrayUtils.toArray(usernames)))
return fetchWithPost(serverUrl, endpoint, options)
}
private suspend fun fetch(serverUrl: String, endpoint: String): ReadableMap? {
return suspendCoroutine { cont ->
Network.get(serverUrl, endpoint, null, object : ResolvePromise() {
override fun resolve(value: Any?) {
val response = value as ReadableMap?
if (response != null && !response.getBoolean("ok")) {
val error = response.getMap("data")
cont.resumeWith(Result.failure((IOException("Unexpected code ${error?.getInt("status_code")} ${error?.getString("message")}"))))
for(i in 0 until it.size()) {
val thread = it.getMap(i)
val threadId = thread.getString("id")
if (threadId != null) {
if (threadIds.contains(threadId)) {
// replace the values for participants and is_following
val index = threadsArray.indexOfFirst { el -> el.getString("id") == threadId }
val prev = threadsArray[index]
val merge = Arguments.createMap()
merge.merge(prev)
merge.putBoolean("is_following", thread.getBoolean("is_following"))
merge.putArray("participants", thread.getArray("participants"))
threadsArray[index] = merge
} else {
cont.resumeWith(Result.success(response))
threadsArray.add(thread)
threadIds.add(threadId)
}
}
override fun reject(code: String, message: String) {
cont.resumeWith(Result.failure(IOException("Unexpected code $code $message")))
}
override fun reject(reason: Throwable?) {
cont.resumeWith(Result.failure(IOException("Unexpected code $reason")))
}
})
}
return threadsArray
}
}
private suspend fun fetchWithPost(serverUrl: String, endpoint: String, options: ReadableMap?) : ReadableMap? {
return suspendCoroutine { cont ->
Network.post(serverUrl, endpoint, options, object : ResolvePromise() {
override fun resolve(value: Any?) {
val response = value as ReadableMap?
cont.resumeWith(Result.success(response))
}
override fun reject(code: String, message: String) {
cont.resumeWith(Result.failure(IOException("Unexpected code $code $message")))
}
override fun reject(reason: Throwable?) {
cont.resumeWith(Result.failure(IOException("Unexpected code $reason")))
}
})
}
return null
}
}
}

View File

@@ -2,6 +2,7 @@ package com.mattermost.helpers;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.ReadableType;
import com.facebook.react.bridge.WritableArray;
@@ -109,7 +110,9 @@ public class ReadableArrayUtils {
writableArray.pushString((String) value);
} else if (value instanceof Map) {
writableArray.pushMap(ReadableMapUtils.toWritableMap((Map<String, Object>) value));
} else if (value.getClass().isArray()) {
} else if (value instanceof ReadableMap) {
writableArray.pushMap((ReadableMap) value);
}else if (value.getClass().isArray()) {
writableArray.pushArray(ReadableArrayUtils.toWritableArray((Object[]) value));
}
}

View File

@@ -122,24 +122,37 @@ public class RealPathUtil {
File cacheDir = new File(context.getCacheDir(), CACHE_DIR_NAME);
if (!cacheDir.exists()) {
cacheDir.mkdirs();
boolean cacheDirExists = cacheDir.exists();
if (!cacheDirExists) {
cacheDirExists = cacheDir.mkdirs();
}
tmpFile = new File(cacheDir, fileName);
tmpFile.createNewFile();
if (cacheDirExists) {
tmpFile = new File(cacheDir, fileName);
boolean fileCreated = tmpFile.createNewFile();
ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(uri, "r");
if (fileCreated) {
ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(uri, "r");
FileChannel src = new FileInputStream(pfd.getFileDescriptor()).getChannel();
FileChannel dst = new FileOutputStream(tmpFile).getChannel();
dst.transferFrom(src, 0, src.size());
src.close();
dst.close();
try (FileInputStream inputSrc = new FileInputStream(pfd.getFileDescriptor())) {
FileChannel src = inputSrc.getChannel();
try (FileOutputStream outputDst = new FileOutputStream(tmpFile)) {
FileChannel dst = outputDst.getChannel();
dst.transferFrom(src, 0, src.size());
src.close();
dst.close();
}
}
pfd.close();
}
return tmpFile.getAbsolutePath();
}
} catch (IOException ex) {
return null;
ex.printStackTrace();
}
return tmpFile.getAbsolutePath();
return null;
}
public static String getDataColumn(Context context, Uri uri, String selection,
@@ -245,7 +258,9 @@ public class RealPathUtil {
}
}
fileOrDirectory.delete();
if (!fileOrDirectory.delete()) {
Log.i("ReactNative", "Couldn't delete file " + fileOrDirectory.getName());
}
}
private static String sanitizeFilename(String filename) {
@@ -256,22 +271,4 @@ public class RealPathUtil {
File f = new File(filename);
return f.getName();
}
public static File createDirIfNotExists(String path) {
File dir = new File(path);
if (dir.exists()) {
return dir;
}
try {
dir.mkdirs();
// Add .nomedia to hide the thumbnail directory from gallery
File noMedia = new File(path, ".nomedia");
noMedia.createNewFile();
} catch (IOException e) {
e.printStackTrace();
}
return dir;
}
}

View File

@@ -0,0 +1,87 @@
package com.mattermost.helpers.database_extension
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.nozbe.watermelondb.Database
fun insertCategory(db: Database, category: ReadableMap) {
try {
val id = category.getString("id") ?: return
val collapsed = false
val displayName = category.getString("display_name")
val muted = category.getBoolean("muted")
val sortOrder = category.getInt("sort_order")
val sorting = category.getString("sorting") ?: "recent"
val teamId = category.getString("team_id")
val type = category.getString("type")
db.execute(
"""
INSERT INTO Category
(id, collapsed, display_name, muted, sort_order, sorting, team_id, type, _changed, _status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(
id, collapsed, displayName, muted,
sortOrder / 10, sorting, teamId, type
)
)
} catch (e: Exception) {
e.printStackTrace()
}
}
fun insertCategoryChannels(db: Database, categoryId: String, teamId: String, channelIds: ReadableArray) {
try {
for (i in 0 until channelIds.size()) {
val channelId = channelIds.getString(i)
val id = "${teamId}_$channelId"
db.execute(
"""
INSERT INTO CategoryChannel
(id, category_id, channel_id, sort_order, _changed, _status)
VALUES (?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(id, categoryId, channelId, i)
)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
fun insertCategoriesWithChannels(db: Database, orderCategories: ReadableMap) {
val categories = orderCategories.getArray("categories") ?: return
for (i in 0 until categories.size()) {
val category = categories.getMap(i)
val id = category.getString("id")
val teamId = category.getString("team_id")
val channelIds = category.getArray("channel_ids")
insertCategory(db, category)
if (id != null && teamId != null) {
channelIds?.let { insertCategoryChannels(db, id, teamId, it) }
}
}
}
fun insertChannelToDefaultCategory(db: Database, categoryChannels: ReadableArray) {
try {
for (i in 0 until categoryChannels.size()) {
val cc = categoryChannels.getMap(i)
val id = cc.getString("id")
val categoryId = cc.getString("category_id")
val channelId = cc.getString("channel_id")
val count = countByColumn(db, "CategoryChannel", "category_id", categoryId)
db.execute(
"""
INSERT INTO CategoryChannel
(id, category_id, channel_id, sort_order, _changed, _status)
VALUES (?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(id, categoryId, channelId, if (count > 0) count + 1 else count)
)
}
} catch (e: Exception) {
e.printStackTrace()
}
}

View File

@@ -0,0 +1,220 @@
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 org.json.JSONException
import org.json.JSONObject
fun findChannel(db: Database?, channelId: String): Boolean {
if (db != null) {
val team = find(db, "Channel", channelId)
return team != null
}
return false
}
fun findMyChannel(db: Database?, channelId: String): Boolean {
if (db != null) {
val team = find(db, "MyChannel", channelId)
return team != null
}
return false
}
internal fun handleChannel(db: Database, channel: ReadableMap) {
try {
val exists = channel.getString("id")?.let { findChannel(db, it) } ?: false
if (!exists) {
val json = ReadableMapUtils.toJSONObject(channel)
if (insertChannel(db, json)) {
insertChannelInfo(db, json)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
internal fun DatabaseHelper.handleMyChannel(db: Database, myChannel: ReadableMap, postsData: ReadableMap?, receivingThreads: Boolean) {
try {
val json = ReadableMapUtils.toJSONObject(myChannel)
val exists = myChannel.getString("id")?.let { findMyChannel(db, it) } ?: false
if (postsData != null && !receivingThreads) {
val posts = ReadableMapUtils.toJSONObject(postsData.getMap("posts")).toMap()
val postList = posts.toList()
val lastFetchedAt = postList.fold(0.0) { acc, next ->
val post = next.second as Map<*, *>
val createAt = post["create_at"] as Double
val updateAt = post["update_at"] as Double
val deleteAt = post["delete_at"] as Double
val value = maxOf(createAt, updateAt, deleteAt)
maxOf(value, acc)
}
json.put("last_fetched_at", lastFetchedAt)
}
if (exists) {
updateMyChannel(db, json)
return
}
if (insertMyChannel(db, json)) {
insertMyChannelSettings(db, json)
insertChannelMember(db, json)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
fun insertChannel(db: Database, 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 }
val updateAt = try { channel.getDouble("update_at") } catch (e: JSONException) { 0 }
val creatorId = try { channel.getString("creator_id") } catch (e: JSONException) { "" }
val displayName = try { channel.getString("display_name") } catch (e: JSONException) { "" }
val name = try { channel.getString("name") } catch (e: JSONException) { "" }
val teamId = try { channel.getString("team_id") } catch (e: JSONException) { "" }
val type = try { channel.getString("type") } catch (e: JSONException) { "O" }
val isGroupConstrained = try { channel.getBoolean("group_constrained") } catch (e: JSONException) { false }
val shared = try { channel.getBoolean("shared") } catch (e: JSONException) { false }
return try {
db.execute(
"""
INSERT INTO Channel
(id, create_at, delete_at, update_at, creator_id, display_name, name, team_id, type, is_group_constrained, shared, _changed, _status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(
id, createAt, deleteAt, updateAt,
creatorId, displayName, name, teamId, type,
isGroupConstrained, shared
)
)
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
fun insertChannelInfo(db: Database, 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) { "" }
try {
db.execute(
"""
INSERT INTO ChannelInfo
(id, header, purpose, guest_count, member_count, pinned_post_count, _changed, _status)
VALUES (?, ?, ?, 0, 0, 0, '', 'created')
""".trimIndent(),
arrayOf(id, header, purpose)
)
} catch (e: Exception) {
e.printStackTrace()
}
}
fun insertMyChannel(db: Database, 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) { "" }
val msgCount = try { myChanel.getInt("message_count") } catch (e: JSONException) { 0 }
val mentionsCount = try { myChanel.getInt("mentions_count") } catch (e: JSONException) { 0 }
val isUnread = try { myChanel.getBoolean("is_unread") } catch (e: JSONException) { false }
val lastPostAt = try { myChanel.getDouble("last_post_at") } catch (e: JSONException) { 0 }
val lastViewedAt = try { myChanel.getDouble("last_viewed_at") } catch (e: JSONException) { 0 }
val viewedAt = 0
val lastFetchedAt = try { myChanel.getDouble("last_fetched_at") } catch (e: JSONException) { 0 }
val manuallyUnread = false
db.execute(
"""
INSERT INTO MyChannel
(id, roles, message_count, mentions_count, is_unread, manually_unread,
last_post_at, last_viewed_at, viewed_at, last_fetched_at, _changed, _status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'created')
""",
arrayOf(
id, roles, msgCount, mentionsCount, isUnread, manuallyUnread,
lastPostAt, lastViewedAt, viewedAt, lastFetchedAt
)
)
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
fun insertMyChannelSettings(db: Database, myChanel: JSONObject) {
try {
val id = try { myChanel.getString("id") } catch (e: JSONException) { return }
val notifyProps = try { myChanel.getString("notify_props") } catch (e: JSONException) { return }
db.execute(
"""
INSERT INTO MyChannelSettings (id, notify_props, _changed, _status)
VALUES (?, ?, '', 'created')
""",
arrayOf(id, notifyProps)
)
} catch (e: Exception) {
e.printStackTrace()
}
}
fun insertChannelMember(db: Database, myChanel: JSONObject) {
try {
val userId = queryCurrentUserId(db) ?: return
val channelId = try { myChanel.getString("id") } catch (e: JSONException) { return }
val schemeAdmin = try { myChanel.getBoolean("scheme_admin") } catch (e: JSONException) { false }
val id = "$channelId-$userId"
db.execute(
"""
INSERT INTO ChannelMembership
(id, channel_id, user_id, scheme_admin, _changed, _status)
VALUES (?, ?, ?, ?, '', 'created')
""",
arrayOf(id, channelId, userId, schemeAdmin)
)
} catch (e: Exception) {
e.printStackTrace()
}
}
fun updateMyChannel(db: Database, myChanel: JSONObject) {
try {
val id = try { myChanel.getString("id") } catch (e: JSONException) { return }
val msgCount = try { myChanel.getInt("message_count") } catch (e: JSONException) { 0 }
val mentionsCount = try { myChanel.getInt("mentions_count") } catch (e: JSONException) { 0 }
val isUnread = try { myChanel.getBoolean("is_unread") } catch (e: JSONException) { false }
val lastPostAt = try { myChanel.getDouble("last_post_at") } catch (e: JSONException) { 0 }
val lastViewedAt = try { myChanel.getDouble("last_viewed_at") } catch (e: JSONException) { 0 }
val lastFetchedAt = try { myChanel.getDouble("last_fetched_at") } catch (e: JSONException) { 0 }
db.execute(
"""
UPDATE MyChannel SET message_count=?, mentions_count=?, is_unread=?,
last_post_at=?, last_viewed_at=?, last_fetched_at=?, _status = 'updated'
WHERE id=?
""",
arrayOf(
msgCount, mentionsCount, isUnread,
lastPostAt, lastViewedAt, lastFetchedAt, id
)
)
} catch (e: Exception) {
e.printStackTrace()
}
}

View File

@@ -0,0 +1,23 @@
package com.mattermost.helpers.database_extension
import com.nozbe.watermelondb.Database
import org.json.JSONArray
internal fun insertCustomEmojis(db: Database, customEmojis: JSONArray) {
for (i in 0 until customEmojis.length()) {
try {
val emoji = customEmojis.getJSONObject(i)
if (find(db, "CustomEmoji", emoji.getString("id")) == null) {
db.execute(
"INSERT INTO CustomEmoji (id, name, _changed, _status) VALUES (?, ?, '', 'created')",
arrayOf(
emoji.getString("id"),
emoji.getString("name"),
)
)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}

View File

@@ -0,0 +1,35 @@
package com.mattermost.helpers.database_extension
import com.nozbe.watermelondb.Database
import org.json.JSONArray
import org.json.JSONException
internal fun insertFiles(db: Database, files: JSONArray) {
try {
for (i in 0 until files.length()) {
val file = files.getJSONObject(i)
val id = file.getString("id")
val extension = file.getString("extension")
val miniPreview = try { file.getString("mini_preview") } catch (e: JSONException) { "" }
val height = try { file.getInt("height") } catch (e: JSONException) { 0 }
val mime = file.getString("mime_type")
val name = file.getString("name")
val postId = file.getString("post_id")
val size = try { file.getDouble("size") } catch (e: JSONException) { 0 }
val width = try { file.getInt("width") } catch (e: JSONException) { 0 }
db.execute(
"""
INSERT INTO File
(id, extension, height, image_thumbnail, local_path, mime_type, name, post_id, size, width, _changed, _status)
VALUES (?, ?, ?, ?, '', ?, ?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(
id, extension, height, miniPreview,
mime, name, postId, size, width
)
)
}
} catch (e: Exception) {
e.printStackTrace()
}
}

View File

@@ -0,0 +1,168 @@
package com.mattermost.helpers.database_extension
import android.content.Context
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 java.util.*
import kotlin.Exception
internal fun DatabaseHelper.saveToDatabase(db: Database, data: ReadableMap, teamId: String?, channelId: String?, receivingThreads: Boolean) {
db.transaction {
val posts = data.getMap("posts")
data.getMap("team")?.let { insertTeam(db, it) }
data.getMap("myTeam")?.let { insertMyTeam(db, it) }
data.getMap("channel")?.let { handleChannel(db, it) }
data.getMap("myChannel")?.let { handleMyChannel(db, it, posts, receivingThreads) }
data.getMap("categories")?.let { insertCategoriesWithChannels(db, it) }
data.getArray("categoryChannels")?.let { insertChannelToDefaultCategory(db, it) }
if (channelId != null) {
handlePosts(db, posts, channelId, receivingThreads)
}
data.getArray("threads")?.let {
val threadsArray = ArrayList<ReadableMap>()
for (i in 0 until it.size()) {
threadsArray.add(it.getMap(i))
}
handleThreads(db, threadsArray, teamId)
}
data.getArray("users")?.let { handleUsers(db, it) }
}
}
fun DatabaseHelper.getServerUrlForIdentifier(identifier: String): String? {
try {
val query = "SELECT url FROM Servers WHERE identifier=?"
defaultDatabase!!.rawQuery(query, arrayOf(identifier)).use { cursor ->
if (cursor.count == 1) {
cursor.moveToFirst()
return cursor.getString(0)
}
}
} catch (e: Exception) {
e.printStackTrace()
// let it fall to return null
}
return null
}
fun DatabaseHelper.getDatabaseForServer(context: Context?, serverUrl: String): Database? {
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(databasePath, context!!)
}
}
} catch (e: Exception) {
e.printStackTrace()
// let it fall to return null
}
return null
}
fun find(db: Database, tableName: String, id: String?): ReadableMap? {
try {
db.rawQuery(
"SELECT * FROM $tableName WHERE id == ? LIMIT 1",
arrayOf(id)
).use { cursor ->
if (cursor.count <= 0) {
return null
}
val resultMap = Arguments.createMap()
cursor.moveToFirst()
resultMap.mapCursor(cursor)
return resultMap
}
} catch (e: Exception) {
e.printStackTrace()
return null
}
}
fun findByColumns(db: Database, tableName: String, columnNames: Array<String>, values: QueryArgs): ReadableMap? {
try {
val whereString = columnNames.joinToString(" AND ") { "$it = ?" }
db.rawQuery(
"SELECT * FROM $tableName WHERE $whereString LIMIT 1",
values
).use { cursor ->
if (cursor.count <= 0) {
return null
}
val resultMap = Arguments.createMap()
cursor.moveToFirst()
resultMap.mapCursor(cursor)
return resultMap
}
} catch (e: Exception) {
e.printStackTrace()
return null
}
}
fun queryIds(db: Database, tableName: String, ids: Array<String>): List<String> {
val list: MutableList<String> = ArrayList()
val args = TextUtils.join(",", Arrays.stream(ids).map { "?" }.toArray())
try {
@Suppress("UNCHECKED_CAST")
db.rawQuery("SELECT DISTINCT id FROM $tableName WHERE id IN ($args)", ids as Array<Any?>).use { cursor ->
if (cursor.count > 0) {
while (cursor.moveToNext()) {
val index = cursor.getColumnIndex("id")
if (index >= 0) {
list.add(cursor.getString(index))
}
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return list
}
fun queryByColumn(db: Database, tableName: String, columnName: String, values: Array<Any?>): List<String> {
val list: MutableList<String> = ArrayList()
val args = TextUtils.join(",", Arrays.stream(values).map { "?" }.toArray())
try {
db.rawQuery("SELECT DISTINCT $columnName FROM $tableName WHERE $columnName IN ($args)", values).use { cursor ->
if (cursor.count > 0) {
while (cursor.moveToNext()) {
val index = cursor.getColumnIndex(columnName)
if (index >= 0) {
list.add(cursor.getString(index))
}
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return list
}
fun countByColumn(db: Database, tableName: String, columnName: String, value: Any?): Int {
try {
db.rawQuery(
"SELECT COUNT(*) FROM $tableName WHERE $columnName == ? LIMIT 1",
arrayOf(value)
).use { cursor ->
if (cursor.count <= 0) {
return 0
}
cursor.moveToFirst()
return cursor.getInt(0)
}
} catch (e: Exception) {
e.printStackTrace()
return 0
}
}

View File

@@ -0,0 +1,256 @@
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 org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import kotlin.Exception
internal fun queryLastPostCreateAt(db: Database?, channelId: String): Double? {
try {
if (db != null) {
val postsInChannelQuery = "SELECT earliest, latest FROM PostsInChannel WHERE channel_id=? ORDER BY latest DESC LIMIT 1"
db.rawQuery(postsInChannelQuery, arrayOf(channelId)).use { cursor1 ->
if (cursor1.count == 1) {
cursor1.moveToFirst()
val earliest = cursor1.getDouble(0)
val latest = cursor1.getDouble(1)
val postQuery = "SELECT create_at FROM POST WHERE channel_id= ? AND delete_at=0 AND create_at BETWEEN ? AND ? ORDER BY create_at DESC"
db.rawQuery(postQuery, arrayOf(channelId, earliest, latest)).use { cursor2 ->
if (cursor2.count >= 60) {
cursor2.moveToFirst()
return cursor2.getDouble(0)
}
}
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return null
}
fun queryPostSinceForChannel(db: Database?, channelId: String): Double? {
try {
if (db != null) {
val postsInChannelQuery = "SELECT last_fetched_at FROM MyChannel WHERE id=? LIMIT 1"
db.rawQuery(postsInChannelQuery, arrayOf(channelId)).use { cursor ->
if (cursor.count == 1) {
cursor.moveToFirst()
val lastFetchedAt = cursor.getDouble(0)
if (lastFetchedAt == 0.0) {
return queryLastPostCreateAt(db, channelId)
}
return lastFetchedAt
}
}
}
} catch (e: Exception) {
e.printStackTrace()
// let it fall to return null
}
return null
}
fun queryLastPostInThread(db: Database?, 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"
db.rawQuery(query, arrayOf(rootId)).use { cursor ->
if (cursor.count == 1) {
cursor.moveToFirst()
return cursor.getDouble(0)
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return null
}
internal fun insertPost(db: Database, post: JSONObject) {
try {
val id = try { post.getString("id") } catch (e: JSONException) { return }
val channelId = try { post.getString("channel_id") } catch (e: JSONException) { return }
val userId = try { post.getString("user_id") } catch (e: JSONException) { return }
val createAt = try { post.getDouble("create_at") } catch (e: JSONException) { return }
val deleteAt = try { post.getDouble("delete_at") } catch (e: JSONException) { 0 }
val updateAt = try { post.getDouble("update_at") } catch (e: JSONException) { 0 }
val editAt = try { post.getDouble("edit_at") } catch (e: JSONException) { 0 }
val isPinned = try { post.getBoolean("is_pinned") } catch (e: JSONException) { false }
val message = try { post.getString("message") } catch (e: JSONException) { "" }
val metadata = try { post.getJSONObject("metadata") } catch (e: JSONException) { JSONObject() }
val originalId = try { post.getString("original_id") } catch (e: JSONException) { "" }
val pendingId = try { post.getString("pending_post_id") } catch (e: JSONException) { "" }
val prevId = try { post.getString("prev_post_id") } catch (e: JSONException) { "" }
val rootId = try { post.getString("root_id") } catch (e: JSONException) { "" }
val type = try { post.getString("type") } catch (e: JSONException) { "" }
val props = try { post.getJSONObject("props").toString() } catch (e: JSONException) { "" }
val reactions = metadata.remove("reactions") as JSONArray?
val customEmojis = metadata.remove("emojis") as JSONArray?
val files = metadata.remove("files") as JSONArray?
db.execute(
"""
INSERT INTO Post
(id, channel_id, create_at, delete_at, update_at, edit_at, is_pinned, message, metadata, original_id, pending_post_id,
previous_post_id, root_id, type, user_id, props, _changed, _status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(
id, channelId, createAt, deleteAt, updateAt, editAt,
isPinned, message, metadata.toString(),
originalId, pendingId, prevId, rootId,
type, userId, props
)
)
if (reactions != null && reactions.length() > 0) {
insertReactions(db, reactions)
}
if (customEmojis != null && customEmojis.length() > 0) {
insertCustomEmojis(db, customEmojis)
}
if (files != null && files.length() > 0) {
insertFiles(db, files)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
internal fun updatePost(db: Database, post: JSONObject) {
try {
val id = try { post.getString("id") } catch (e: JSONException) { return }
val channelId = try { post.getString("channel_id") } catch (e: JSONException) { return }
val userId = try { post.getString("user_id") } catch (e: JSONException) { return }
val createAt = try { post.getDouble("create_at") } catch (e: JSONException) { return }
val deleteAt = try { post.getDouble("delete_at") } catch (e: JSONException) { 0 }
val updateAt = try { post.getDouble("update_at") } catch (e: JSONException) { 0 }
val editAt = try { post.getDouble("edit_at") } catch (e: JSONException) { 0 }
val isPinned = try { post.getBoolean("is_pinned") } catch (e: JSONException) { false }
val message = try { post.getString("message") } catch (e: JSONException) { "" }
val metadata = try { post.getJSONObject("metadata") } catch (e: JSONException) { JSONObject() }
val originalId = try { post.getString("original_id") } catch (e: JSONException) { "" }
val pendingId = try { post.getString("pending_post_id") } catch (e: JSONException) { "" }
val prevId = try { post.getString("prev_post_id") } catch (e: JSONException) { "" }
val rootId = try { post.getString("root_id") } catch (e: JSONException) { "" }
val type = try { post.getString("type") } catch (e: JSONException) { "" }
val props = try { post.getJSONObject("props").toString() } catch (e: JSONException) { "" }
val reactions = metadata.remove("reactions") as JSONArray?
val customEmojis = metadata.remove("emojis") as JSONArray?
metadata.remove("files")
db.execute(
"""
UPDATE Post SET channel_id = ?, create_at = ?, delete_at = ?, update_at =?, edit_at =?,
is_pinned = ?, message = ?, metadata = ?, original_id = ?, pending_post_id = ?, previous_post_id = ?,
root_id = ?, type = ?, user_id = ?, props = ?, _status = 'updated'
WHERE id = ?
""".trimIndent(),
arrayOf(
channelId, createAt, deleteAt, updateAt, editAt,
isPinned, message, metadata.toString(),
originalId, pendingId, prevId, rootId,
type, userId, props,
id,
)
)
if (reactions != null && reactions.length() > 0) {
db.execute("DELETE FROM Reaction WHERE post_id = ?", arrayOf(post.getString("id")))
insertReactions(db, reactions)
}
if (customEmojis != null && customEmojis.length() > 0) {
insertCustomEmojis(db, customEmojis)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
fun DatabaseHelper.handlePosts(db: Database, postsData: ReadableMap?, channelId: String, receivingThreads: Boolean) {
// Posts, PostInChannel, PostInThread, Reactions, Files, CustomEmojis, Users
try {
if (postsData != null) {
val ordered = postsData.getArray("order")?.toArrayList()
val posts = ReadableMapUtils.toJSONObject(postsData.getMap("posts")).toMap()
val previousPostId = postsData.getString("prev_post_id")
val postsInThread = hashMapOf<String, List<JSONObject>>()
val postList = posts.toList()
var earliest = 0.0
var latest = 0.0
if (ordered != null && posts.isNotEmpty()) {
val firstId = ordered.first()
val lastId = ordered.last()
var prevPostId = ""
val sortedPosts = postList.sortedBy { (_, value) ->
((value as Map<*, *>)["create_at"] as Double)
}
sortedPosts.forEachIndexed { index, it ->
val key = it.first
if (it.second != null) {
@Suppress("UNCHECKED_CAST", "UNCHECKED_CAST")
val post: MutableMap<String, Any?> = it.second as MutableMap<String, Any?>
if (index == 0) {
post.putIfAbsent("prev_post_id", previousPostId)
} else if (prevPostId.isNotEmpty()) {
post.putIfAbsent("prev_post_id", prevPostId)
}
if (lastId == key) {
earliest = post["create_at"] as Double
}
if (firstId == key) {
latest = post["create_at"] as Double
}
val jsonPost = JSONObject(post)
val postId = post["id"] as? String ?: ""
val rootId = post["root_id"] as? String ?: ""
val postInThread = rootId.ifEmpty { postId }
var thread = postsInThread[postInThread]?.toMutableList()
if (thread == null) {
thread = mutableListOf()
}
thread.add(jsonPost)
postsInThread[postInThread] = thread.toList()
if (find(db, "Post", key) == null) {
insertPost(db, jsonPost)
} else {
updatePost(db, jsonPost)
}
if (ordered.contains(key)) {
prevPostId = key
}
}
}
}
if (!receivingThreads) {
handlePostsInChannel(db, channelId, earliest, latest)
}
handlePostsInThread(db, postsInThread)
}
} catch (e: Exception) {
e.printStackTrace()
}
}

View File

@@ -0,0 +1,97 @@
package com.mattermost.helpers.database_extension
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
internal fun findPostInChannel(chunks: ReadableArray, earliest: Double, latest: Double): ReadableMap? {
for (i in 0 until chunks.size()) {
val chunk = chunks.getMap(i)
if (earliest >= chunk.getDouble("earliest") || latest <= chunk.getDouble("latest")) {
return chunk
}
}
return null
}
internal fun insertPostInChannel(db: Database, channelId: String, earliest: Double, latest: Double): ReadableMap? {
return try {
val id = RandomId.generate()
db.execute(
"""
INSERT INTO PostsInChannel
(id, channel_id, earliest, latest, _changed, _status)
VALUES (?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(id, channelId, earliest, latest))
val map = Arguments.createMap()
map.putString("id", id)
map.putString("channel_id", channelId)
map.putDouble("earliest", earliest)
map.putDouble("latest", latest)
map
} catch (e: Exception) {
e.printStackTrace()
null
}
}
internal fun mergePostsInChannel(db: Database, existingChunks: ReadableArray, newChunk: ReadableMap) {
for (i in 0 until existingChunks.size()) {
try {
val chunk = existingChunks.getMap(i)
if (newChunk.getDouble("earliest") <= chunk.getDouble("earliest") &&
newChunk.getDouble("latest") >= chunk.getDouble("latest")) {
db.execute("DELETE FROM PostsInChannel WHERE id = ?", arrayOf(chunk.getString("id")))
break
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
internal fun handlePostsInChannel(db: Database, channelId: String, earliest: Double, latest: Double) {
try {
db.rawQuery(
"SELECT id, channel_id, earliest, latest FROM PostsInChannel WHERE channel_id = ?",
arrayOf(channelId)
).use { cursor ->
if (cursor.count == 0) {
// create new post in channel
insertPostInChannel(db, channelId, earliest, latest)
return
}
val resultArray = Arguments.createArray()
while (cursor.moveToNext()) {
val cursorMap = Arguments.createMap()
cursorMap.mapCursor(cursor)
resultArray.pushMap(cursorMap)
}
val chunk = findPostInChannel(resultArray, earliest, latest)
if (chunk != null) {
db.execute(
"UPDATE PostsInChannel SET earliest = ?, latest = ?, _status = 'updated' WHERE id = ?",
arrayOf(
minOf(earliest, chunk.getDouble("earliest")),
maxOf(latest, chunk.getDouble("latest")),
chunk.getString("id")
)
)
return
}
val newChunk = insertPostInChannel(db, channelId, earliest, latest)
newChunk?.let { mergePostsInChannel(db, resultArray, it) }
}
} catch (e: Exception) {
e.printStackTrace()
}
}

View File

@@ -0,0 +1,29 @@
package com.mattermost.helpers.database_extension
import com.facebook.react.bridge.Arguments
import com.nozbe.watermelondb.Database
import com.nozbe.watermelondb.mapCursor
fun getTeammateDisplayNameSetting(db: Database): String {
val configSetting = queryConfigDisplayNameSetting(db)
if (configSetting != null) {
return configSetting
}
try {
db.rawQuery(
"SELECT value FROM Preference where category = ? AND name = ? limit 1",
arrayOf("display_settings", "name_format")
).use { cursor ->
if (cursor.count <= 0) {
return "username"
}
val resultMap = Arguments.createMap()
cursor.moveToFirst()
resultMap.mapCursor(cursor)
return resultMap?.getString("value") ?: "username"
}
} catch (e: Exception) {
return "username"
}
}

View File

@@ -0,0 +1,28 @@
package com.mattermost.helpers.database_extension
import com.mattermost.helpers.RandomId
import com.nozbe.watermelondb.Database
import org.json.JSONArray
internal fun insertReactions(db: Database, reactions: JSONArray) {
for (i in 0 until reactions.length()) {
try {
val reaction = reactions.getJSONObject(i)
val id = RandomId.generate()
db.execute(
"""
INSERT INTO Reaction
(id, create_at, emoji_name, post_id, user_id, _changed, _status)
VALUES (?, ?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(
id,
reaction.getDouble("create_at"), reaction.getString("emoji_name"),
reaction.getString("post_id"), reaction.getString("user_id")
)
)
} catch (e: Exception) {
e.printStackTrace()
}
}
}

View File

@@ -0,0 +1,32 @@
package com.mattermost.helpers.database_extension
import com.nozbe.watermelondb.Database
import org.json.JSONObject
fun queryCurrentUserId(db: Database): String? {
val result = find(db, "System", "currentUserId")
return result?.getString("value")?.removeSurrounding("\"")
}
fun queryCurrentTeamId(db: Database): String? {
val result = find(db, "System", "currentTeamId")
return result?.getString("value")?.removeSurrounding("\"")
}
fun queryConfigDisplayNameSetting(db: Database): String? {
val license = find(db, "System", "license")
val lockDisplayName = find(db, "Config", "LockTeammateNameDisplay")
val displayName = find(db, "Config", "TeammateNameDisplay")
val licenseValue = license?.getString("value") ?: ""
val lockDisplayNameValue = lockDisplayName?.getString("value") ?: "false"
val displayNameValue = displayName?.getString("value") ?: "full_name"
val licenseJson = JSONObject(licenseValue)
val licenseLock = try { licenseJson.getString("LockTeammateNameDisplay") } catch (e: Exception) { "false"}
if (licenseLock == "true" && lockDisplayNameValue == "true") {
return displayNameValue
}
return null
}

View File

@@ -0,0 +1,106 @@
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
fun findTeam(db: Database?, teamId: String): Boolean {
if (db != null) {
val team = find(db, "Team", teamId)
return team != null
}
return false
}
fun findMyTeam(db: Database?, teamId: String): Boolean {
if (db != null) {
val team = find(db, "MyTeam", teamId)
return team != null
}
return false
}
fun queryMyTeams(db: Database?): ArrayList<ReadableMap>? {
db?.rawQuery("SELECT * FROM MyTeam")?.use { cursor ->
val results = ArrayList<ReadableMap>()
if (cursor.count > 0) {
while(cursor.moveToNext()) {
val map = Arguments.createMap()
map.mapCursor(cursor)
results.add(map)
}
}
return results
}
return null
}
fun insertTeam(db: Database, 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) {
return false
}
val isAllowOpenInvite = try { team.getBoolean("allow_open_invite") } catch (e: NoSuchKeyException) { false }
val description = try { team.getString("description") } catch (e: NoSuchKeyException) { "" }
val displayName = try { team.getString("display_name") } catch (e: NoSuchKeyException) { "" }
val name = try { team.getString("name") } catch (e: NoSuchKeyException) { "" }
val updateAt = try { team.getDouble("update_at") } catch (e: NoSuchKeyException) { 0 }
val type = try { team.getString("type") } catch (e: NoSuchKeyException) { "O" }
val allowedDomains = try { team.getString("allowed_domains") } catch (e: NoSuchKeyException) { "" }
val isGroupConstrained = try { team.getBoolean("group_constrained") } catch (e: NoSuchKeyException) { false }
val lastTeamIconUpdatedAt = try { team.getDouble("last_team_icon_update") } catch (e: NoSuchKeyException) { 0 }
val inviteId = try { team.getString("invite_id") } catch (e: NoSuchKeyException) { "" }
val status = "created"
return try {
db.execute(
"""
INSERT INTO Team (
id, allow_open_invite, description, display_name, name, update_at, type, allowed_domains,
group_constrained, last_team_icon_update, invite_id, _changed, _status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)
""".trimIndent(),
arrayOf(
id, isAllowOpenInvite, description, displayName, name, updateAt,
type, allowedDomains, isGroupConstrained, lastTeamIconUpdatedAt, inviteId, status
)
)
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
fun insertMyTeam(db: Database, 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) { "" }
val schemeAdmin = try { myTeam.getBoolean("scheme_admin") } catch (e: NoSuchKeyException) { false }
val status = "created"
val membershipId = "$id-$currentUserId"
return try {
db.execute(
"INSERT INTO MyTeam (id, roles, _changed, _status) VALUES (?, ?, '', ?)",
arrayOf(id, roles, status)
)
db.execute(
"""
INSERT INTO TeamMembership (id, team_id, user_id, scheme_admin, _changed, _status)
VALUES (?, ?, ?, ?, '', ?)
""".trimIndent(),
arrayOf(membershipId, id, currentUserId, schemeAdmin, status)
)
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}

View File

@@ -0,0 +1,247 @@
package com.mattermost.helpers.database_extension
import com.facebook.react.bridge.Arguments
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 org.json.JSONObject
internal fun insertThread(db: Database, thread: ReadableMap) {
// These fields are not present when we extract threads from posts
try {
val id = try { thread.getString("id") } catch (e: NoSuchKeyException) { return }
val isFollowing = try { thread.getBoolean("is_following") } catch (e: NoSuchKeyException) { false }
val lastViewedAt = try { thread.getDouble("last_viewed_at") } catch (e: NoSuchKeyException) { 0 }
val unreadReplies = try { thread.getInt("unread_replies") } catch (e: NoSuchKeyException) { 0 }
val unreadMentions = try { thread.getInt("unread_mentions") } catch (e: NoSuchKeyException) { 0 }
val lastReplyAt = try { thread.getDouble("last_reply_at") } catch (e: NoSuchKeyException) { 0 }
val replyCount = try { thread.getInt("reply_count") } catch (e: NoSuchKeyException) { 0 }
db.execute(
"""
INSERT INTO Thread
(id, last_reply_at, last_fetched_at, last_viewed_at, reply_count, is_following, unread_replies, unread_mentions, viewed_at, _changed, _status)
VALUES (?, ?, 0, ?, ?, ?, ?, ?, 0, '', 'created')
""".trimIndent(),
arrayOf(
id, lastReplyAt, lastViewedAt,
replyCount, isFollowing, unreadReplies, unreadMentions
)
)
} catch (e: Exception) {
e.printStackTrace()
}
}
internal fun updateThread(db: Database, 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 }
val isFollowing = try { thread.getBoolean("is_following") } catch (e: NoSuchKeyException) { existingRecord.getInt("is_following") == 1 }
val lastViewedAt = try { thread.getDouble("last_viewed_at") } catch (e: NoSuchKeyException) { existingRecord.getDouble("last_viewed_at") }
val unreadReplies = try { thread.getInt("unread_replies") } catch (e: NoSuchKeyException) { existingRecord.getInt("unread_replies") }
val unreadMentions = try { thread.getInt("unread_mentions") } catch (e: NoSuchKeyException) { existingRecord.getInt("unread_mentions") }
val lastReplyAt = try { thread.getDouble("last_reply_at") } catch (e: NoSuchKeyException) { 0 }
val replyCount = try { thread.getInt("reply_count") } catch (e: NoSuchKeyException) { 0 }
db.execute(
"""
UPDATE Thread SET
last_reply_at = ?, last_viewed_at = ?, reply_count = ?, is_following = ?, unread_replies = ?,
unread_mentions = ?, _status = 'updated' where id = ?
""".trimIndent(),
arrayOf(
lastReplyAt, lastViewedAt, replyCount,
isFollowing, unreadReplies, unreadMentions, id
)
)
} catch (e: Exception) {
e.printStackTrace()
}
}
internal fun insertThreadParticipants(db: Database, threadId: String, participants: ReadableArray) {
for (i in 0 until participants.size()) {
try {
val participant = participants.getMap(i)
val id = RandomId.generate()
db.execute(
"""
INSERT INTO ThreadParticipant
(id, thread_id, user_id, _changed, _status)
VALUES (?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(id, threadId, participant.getString("id"))
)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
fun insertTeamThreadsSync(db: Database, teamId: String, earliest: Double, latest: Double) {
try {
val query = """
INSERT INTO TeamThreadsSync (id, _changed, _status, earliest, latest)
VALUES (?, '', 'created', ?, ?)
"""
db.execute(query, arrayOf(teamId, earliest, latest))
} catch (e: Exception) {
e.printStackTrace()
}
}
fun updateTeamThreadsSync(db: Database, teamId: String, earliest: Double, latest: Double, existingRecord: ReadableMap) {
try {
val storeEarliest = minOf(earliest, existingRecord.getDouble("earliest"))
val storeLatest = maxOf(latest, existingRecord.getDouble("latest"))
val query = "UPDATE TeamThreadsSync SET earliest=?, latest=? WHERE id=?"
db.execute(query, arrayOf(storeEarliest, storeLatest, teamId))
} catch (e: Exception) {
e.printStackTrace()
}
}
fun syncParticipants(db: Database, thread: ReadableMap) {
try {
val threadId = thread.getString("id")
val participants = thread.getArray("participants")
if (participants != null) {
db.execute("DELETE FROM ThreadParticipant WHERE thread_id = ?", arrayOf(threadId))
if (participants.size() > 0) {
insertThreadParticipants(db, threadId!!, participants)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
internal fun handlePostsInThread(db: Database, postsInThread: Map<String, List<JSONObject>>) {
postsInThread.forEach { (key, list) ->
try {
val sorted = list.sortedBy { it.getDouble("create_at") }
val earliest = sorted.first().getDouble("create_at")
val latest = sorted.last().getDouble("create_at")
db.rawQuery("SELECT * FROM PostsInThread WHERE root_id = ? ORDER BY latest DESC", arrayOf(key)).use { cursor ->
if (cursor.count > 0) {
cursor.moveToFirst()
val cursorMap = Arguments.createMap()
cursorMap.mapCursor(cursor)
val storeEarliest = minOf(earliest, cursorMap.getDouble("earliest"))
val storeLatest = maxOf(latest, cursorMap.getDouble("latest"))
db.execute(
"UPDATE PostsInThread SET earliest = ?, latest = ?, _status = 'updated' WHERE root_id = ?",
arrayOf(
storeEarliest,
storeLatest,
key
)
)
return
}
val id = RandomId.generate()
db.execute(
"""
INSERT INTO PostsInThread
(id, root_id, earliest, latest, _changed, _status)
VALUES (?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(id, key, earliest, latest)
)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
fun handleThreads(db: Database, threads: ArrayList<ReadableMap>, teamId: String?) {
val teamIds = ArrayList<String>()
if (teamId.isNullOrEmpty()) {
val myTeams = queryMyTeams(db)
if (myTeams != null) {
for (myTeam in myTeams) {
myTeam.getString("id")?.let { teamIds.add(it) }
}
}
} else {
teamIds.add(teamId)
}
for (i in 0 until threads.size) {
try {
val thread = threads[i]
handleThread(db, thread, teamIds)
} catch (e: Exception) {
e.printStackTrace()
}
}
handleTeamThreadsSync(db, threads, teamIds)
}
fun handleThread(db: Database, thread: ReadableMap, teamIds: ArrayList<String>) {
// Insert/Update the thread
val threadId = thread.getString("id")
val isFollowing = thread.getBoolean("is_following")
val existingRecord = find(db, "Thread", threadId)
if (existingRecord == null) {
insertThread(db, thread)
} else {
updateThread(db, thread, existingRecord)
}
syncParticipants(db, thread)
// this is per team
if (isFollowing) {
for (teamId in teamIds) {
handleThreadInTeam(db, thread, teamId)
}
}
}
fun handleThreadInTeam(db: Database, thread: ReadableMap, teamId: String) {
val threadId = thread.getString("id") ?: return
val existingRecord = findByColumns(
db,
"ThreadsInTeam",
arrayOf("thread_id", "team_id"),
arrayOf(threadId, teamId)
)
if (existingRecord == null) {
try {
val id = RandomId.generate()
val query = """
INSERT INTO ThreadsInTeam (id, team_id, thread_id, _changed, _status)
VALUES (?, ?, ?, '', 'created')
"""
db.execute(query, arrayOf(id, teamId, threadId))
} catch (e: Exception) {
e.printStackTrace()
}
}
}
fun handleTeamThreadsSync(db: Database, threadList: ArrayList<ReadableMap>, teamIds: ArrayList<String>) {
val sortedList = threadList.filter{ it.getBoolean("is_following") }
.sortedBy { it.getDouble("last_reply_at") }
.map { it.getDouble("last_reply_at") }
val earliest = sortedList.first()
val latest = sortedList.last()
for (teamId in teamIds) {
val existingTeamThreadsSync = find(db, "TeamThreadsSync", teamId)
if (existingTeamThreadsSync == null) {
insertTeamThreadsSync(db, teamId, earliest, latest)
} else {
updateTeamThreadsSync(db, teamId, earliest, latest, existingTeamThreadsSync)
}
}
}

View File

@@ -0,0 +1,85 @@
package com.mattermost.helpers.database_extension
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
fun getLastPictureUpdate(db: Database?, userId: String): Double? {
try {
if (db != null) {
var id = userId
if (userId == "me") {
(queryCurrentUserId(db) ?: userId).also { id = it }
}
val userQuery = "SELECT last_picture_update FROM User WHERE id=?"
db.rawQuery(userQuery, arrayOf(id)).use { cursor ->
if (cursor.count == 1) {
cursor.moveToFirst()
return cursor.getDouble(0)
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return null
}
fun getCurrentUserLocale(db: Database): String {
try {
val currentUserId = queryCurrentUserId(db) ?: return "en"
val userQuery = "SELECT locale FROM User WHERE id=?"
db.rawQuery(userQuery, arrayOf(currentUserId)).use { cursor ->
if (cursor.count == 1) {
cursor.moveToFirst()
return cursor.getString(0)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return "en"
}
fun handleUsers(db: Database, users: ReadableArray) {
for (i in 0 until users.size()) {
val user = users.getMap(i)
val roles = user.getString("roles") ?: ""
val isBot = try {
user.getBoolean("is_bot")
} catch (e: NoSuchKeyException) {
false
}
val lastPictureUpdate = try { user.getDouble("last_picture_update") } catch (e: NoSuchKeyException) { 0 }
try {
db.execute(
"""
INSERT INTO User (id, auth_service, update_at, delete_at, email, first_name, is_bot, is_guest,
last_name, last_picture_update, locale, nickname, position, roles, status, username, notify_props,
props, timezone, _changed, _status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(
user.getString("id"),
user.getString("auth_service"), user.getDouble("update_at"), user.getDouble("delete_at"),
user.getString("email"), user.getString("first_name"), isBot,
roles.contains("system_guest"), user.getString("last_name"), lastPictureUpdate,
user.getString("locale"), user.getString("nickname"), user.getString("position"),
roles, "", user.getString("username"), "{}",
ReadableMapUtils.toJSONObject(user.getMap("props")
?: Arguments.createMap()).toString(),
ReadableMapUtils.toJSONObject(user.getMap("timezone")
?: Arguments.createMap()).toString(),
)
)
} catch (e: Exception) {
e.printStackTrace()
}
}
}

View File

@@ -0,0 +1,70 @@
package com.mattermost.helpers.push_notification
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
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
suspend fun PushNotificationDataRunnable.Companion.fetchMyTeamCategories(db: Database, serverUrl: String, teamId: String): ReadableMap? {
return try {
val userId = queryCurrentUserId(db)
val categories = fetch(serverUrl, "/api/v4/users/$userId/teams/$teamId/channels/categories")
categories?.getMap("data")
} catch (e: Exception) {
e.printStackTrace()
null
}
}
fun PushNotificationDataRunnable.Companion.addToDefaultCategoryIfNeeded(db: Database, channel: ReadableMap): ReadableArray? {
val channelId = channel.getString("id") ?: return null
val channelType = channel.getString("type")
val categoryChannels = Arguments.createArray()
if (channelType == "D" || channelType == "G") {
val myTeams = queryMyTeams(db)
myTeams?.let {
for (myTeam in it) {
val map = categoryChannelForTeam(db, channelId, myTeam.getString("id"), "direct_messages")
if (map != null) {
categoryChannels.pushMap(map)
}
}
}
} else {
val map = categoryChannelForTeam(db, channelId, channel.getString("team_id"), "channels")
if (map != null) {
categoryChannels.pushMap(map)
}
}
return categoryChannels
}
private fun categoryChannelForTeam(db: Database, 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")
categoryId?.let { cId ->
val cc = findByColumns(
db,
"CategoryChannel",
arrayOf("category_id", "channel_id"),
arrayOf(cId, channelId)
)
if (cc == null) {
val map = Arguments.createMap()
map.putString("channel_id", channelId)
map.putString("category_id", cId)
map.putString("id", "${id}_$channelId")
return map
}
}
}
return null
}

View File

@@ -0,0 +1,161 @@
package com.mattermost.helpers.push_notification
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.PushNotificationDataRunnable
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 java.text.Collator
import java.util.Locale
suspend fun PushNotificationDataRunnable.Companion.fetchMyChannel(db: Database, serverUrl: String, channelId: String, isCRTEnabled: Boolean): Triple<ReadableMap?, ReadableMap?, ReadableArray?> {
val channel = fetch(serverUrl, "/api/v4/channels/$channelId")
var channelData = channel?.getMap("data")
val myChannelData = channelData?.let { fetchMyChannelData(serverUrl, channelId, isCRTEnabled, it) }
val channelType = channelData?.getString("type")
var profilesArray: ReadableArray? = null
if (channelData != null && channelType != null && !findChannel(db, channelId)) {
val displayNameSetting = getTeammateDisplayNameSetting(db)
when (channelType) {
"D" -> {
profilesArray = fetchProfileInChannel(db, serverUrl, channelId)
if ((profilesArray?.size() ?: 0) > 0) {
val displayName = displayUsername(profilesArray!!.getMap(0), displayNameSetting)
val data = Arguments.createMap()
data.merge(channelData)
data.putString("display_name", displayName)
channelData = data
}
}
"G" -> {
profilesArray = fetchProfileInChannel(db, serverUrl, channelId)
if ((profilesArray?.size() ?: 0) > 0) {
val localeString = getCurrentUserLocale(db)
val localeArray = localeString.split("-")
val locale = if (localeArray.size == 1) {
Locale(localeString)
} else {
Locale(localeArray[0], localeArray[1])
}
val displayName = displayGroupMessageName(profilesArray!!, locale, displayNameSetting)
val data = Arguments.createMap()
data.merge(channelData)
data.putString("display_name", displayName)
channelData = data
}
}
else -> {}
}
}
return Triple(channelData, myChannelData, profilesArray)
}
private suspend fun PushNotificationDataRunnable.Companion.fetchMyChannelData(serverUrl: String, channelId: String, isCRTEnabled: Boolean, channelData: ReadableMap): ReadableMap? {
try {
val myChannel = fetch(serverUrl, "/api/v4/channels/$channelId/members/me")
val myChannelData = myChannel?.getMap("data")
if (myChannelData != null) {
val data = Arguments.createMap()
data.merge(myChannelData)
data.putString("id", channelId)
val totalMsg = if (isCRTEnabled) {
channelData.getInt("total_msg_count_root")
} else {
channelData.getInt("total_msg_count")
}
val myMsgCount = if (isCRTEnabled) {
myChannelData.getInt("msg_count_root")
} else {
myChannelData.getInt("msg_count")
}
val mentionCount = if (isCRTEnabled) {
myChannelData.getInt("mention_count_root")
} else {
myChannelData.getInt("mention_count")
}
val lastPostAt = if (isCRTEnabled) {
try {
channelData.getDouble("last_root_post_at")
} catch (e: Exception) {
channelData.getDouble("last_post_at")
}
} else {
channelData.getDouble("last_post_at")
}
val messageCount = 0.coerceAtLeast(totalMsg - myMsgCount)
data.putInt("message_count", messageCount)
data.putInt("mentions_count", mentionCount)
data.putBoolean("is_unread", messageCount > 0)
data.putDouble("last_post_at", lastPostAt)
return data
}
} catch (e: Exception) {
e.printStackTrace()
}
return null
}
private suspend fun PushNotificationDataRunnable.Companion.fetchProfileInChannel(db: Database, 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=")
val profilesArray = profilesInChannel?.getArray("data")
val result = Arguments.createArray()
if (profilesArray != null) {
for (i in 0 until profilesArray.size()) {
val profile = profilesArray.getMap(i)
if (profile.getString("id") != currentUserId) {
result.pushMap(profile)
}
}
}
result
} catch (e: Exception) {
e.printStackTrace()
null
}
}
private fun PushNotificationDataRunnable.Companion.displayUsername(user: ReadableMap, displayNameSetting: String): String {
val name = user.getString("username") ?: ""
val nickname = user.getString("nickname")
val firstName = user.getString("first_name") ?: ""
val lastName = user.getString("last_name") ?: ""
return when (displayNameSetting) {
"nickname_full_name" -> {
(nickname ?: "$firstName $lastName").trim()
}
"full_name" -> {
"$firstName $lastName".trim()
}
else -> {
name.trim()
}
}
}
private fun PushNotificationDataRunnable.Companion.displayGroupMessageName(profilesArray: ReadableArray, locale: Locale, displayNameSetting: String): String {
val names = ArrayList<String>()
for (i in 0 until profilesArray.size()) {
val profile = profilesArray.getMap(i)
names.add(displayUsername(profile, displayNameSetting))
}
return names.sortedWith { s1, s2 ->
Collator.getInstance(locale).compare(s1, s2)
}.joinToString(", ").trim()
}

View File

@@ -0,0 +1,53 @@
package com.mattermost.helpers.push_notification
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.Network
import com.mattermost.helpers.PushNotificationDataRunnable
import com.mattermost.helpers.ResolvePromise
import java.io.IOException
import kotlin.coroutines.suspendCoroutine
internal suspend fun PushNotificationDataRunnable.Companion.fetch(serverUrl: String, endpoint: String): ReadableMap? {
return suspendCoroutine { cont ->
Network.get(serverUrl, endpoint, null, object : ResolvePromise() {
override fun resolve(value: Any?) {
val response = value as ReadableMap?
if (response != null && !response.getBoolean("ok")) {
val error = response.getMap("data")
cont.resumeWith(Result.failure((IOException("Unexpected code ${error?.getInt("status_code")} ${error?.getString("message")}"))))
} else {
cont.resumeWith(Result.success(response))
}
}
override fun reject(code: String, message: String) {
cont.resumeWith(Result.failure(IOException("Unexpected code $code $message")))
}
override fun reject(reason: Throwable?) {
cont.resumeWith(Result.failure(IOException("Unexpected code $reason")))
}
})
}
}
internal suspend fun PushNotificationDataRunnable.Companion.fetchWithPost(serverUrl: String, endpoint: String, options: ReadableMap?) : ReadableMap? {
return suspendCoroutine { cont ->
Network.post(serverUrl, endpoint, options, object : ResolvePromise() {
override fun resolve(value: Any?) {
val response = value as ReadableMap?
cont.resumeWith(Result.success(response))
}
override fun reject(code: String, message: String) {
cont.resumeWith(Result.failure(IOException("Unexpected code $code $message")))
}
override fun reject(reason: Throwable?) {
cont.resumeWith(Result.failure(IOException("Unexpected code $reason")))
}
})
}
}

View File

@@ -0,0 +1,182 @@
package com.mattermost.helpers.push_notification
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.NoSuchKeyException
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.WritableNativeArray
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
internal suspend fun PushNotificationDataRunnable.Companion.fetchPosts(
db: Database, serverUrl: String, channelId: String, isCRTEnabled: Boolean,
rootId: String?, loadedProfiles: ReadableArray?
): ReadableMap? {
return try {
val regex = Regex("""\B@(([a-z\d-._]*[a-z\d_])[.-]*)""", setOf(RegexOption.IGNORE_CASE))
val currentUserId = queryCurrentUserId(db)
val currentUser = find(db, "User", currentUserId)
val currentUsername = currentUser?.getString("username")
var additionalParams = ""
if (isCRTEnabled) {
additionalParams = "&collapsedThreads=true&collapsedThreadsExtended=true"
}
val receivingThreads = isCRTEnabled && !rootId.isNullOrEmpty()
val endpoint = if (receivingThreads) {
val since = rootId?.let { queryLastPostInThread(db, it) }
val queryParams = if (since == null) "?perPage=60&fromCreatedAt=0&direction=up" else
"?fromCreateAt=${since.toLong()}&direction=down"
"/api/v4/posts/$rootId/thread$queryParams$additionalParams"
} else {
val since = queryPostSinceForChannel(db, channelId)
val queryParams = if (since == null) "?page=0&per_page=60" else "?since=${since.toLong()}"
"/api/v4/channels/$channelId/posts$queryParams$additionalParams"
}
val postsResponse = fetch(serverUrl, endpoint)
val postData = postsResponse?.getMap("data")
val results = Arguments.createMap()
if (postData != null) {
val data = ReadableMapUtils.toMap(postData)
results.putMap("posts", postData)
if (data != null) {
val postsMap = data["posts"]
if (postsMap != null) {
@Suppress("UNCHECKED_CAST")
val posts = ReadableMapUtils.toWritableMap(postsMap as? Map<String, Any>)
val iterator = posts.keySetIterator()
val userIds = mutableListOf<String>()
val usernames = mutableListOf<String>()
val threads = WritableNativeArray()
val threadParticipantUserIds = mutableListOf<String>() // Used to exclude the "userIds" present in the thread participants
val threadParticipantUsernames = mutableListOf<String>() // Used to exclude the "usernames" present in the thread participants
val threadParticipantUsers = HashMap<String, ReadableMap>() // All unique users from thread participants are stored here
val userIdsAlreadyLoaded = mutableListOf<String>()
if (loadedProfiles != null) {
for (i in 0 until loadedProfiles.size()) {
loadedProfiles.getMap(i).getString("id")?.let { userIdsAlreadyLoaded.add(it) }
}
}
while (iterator.hasNextKey()) {
val key = iterator.nextKey()
val post = posts.getMap(key)
val userId = post?.getString("user_id")
if (userId != null && userId != currentUserId && !userIdsAlreadyLoaded.contains(userId) && !userIds.contains(userId)) {
userIds.add(userId)
}
val message = post?.getString("message")
if (message != null) {
val matchResults = regex.findAll(message)
matchResults.iterator().forEach {
val username = it.value.removePrefix("@")
if (!usernames.contains(username) && currentUsername != username && !specialMentions.contains(username)) {
usernames.add(username)
}
}
}
if (isCRTEnabled) {
// Add root post as a thread
val threadId = post?.getString("root_id")
if (threadId.isNullOrEmpty()) {
post?.let {
val thread = Arguments.createMap()
thread.putString("id", it.getString("id"))
thread.putInt("reply_count", it.getInt("reply_count"))
thread.putDouble("last_reply_at", 0.0)
thread.putDouble("last_viewed_at", 0.0)
thread.putArray("participants", it.getArray("participants"))
thread.putMap("post", it)
thread.putBoolean("is_following", try {
it.getBoolean("is_following")
} catch (e: NoSuchKeyException) {
false
})
thread.putInt("unread_replies", 0)
thread.putInt("unread_mentions", 0)
thread.putDouble("delete_at", it.getDouble("delete_at"))
threads.pushMap(thread)
}
}
// Add participant userIds and usernames to exclude them from getting fetched again
val participants = post?.getArray("participants")
participants?.let {
for (i in 0 until it.size()) {
val participant = it.getMap(i)
val participantId = participant.getString("id")
if (participantId != currentUserId && participantId != null) {
if (!threadParticipantUserIds.contains(participantId) && !userIdsAlreadyLoaded.contains(participantId)) {
threadParticipantUserIds.add(participantId)
}
if (!threadParticipantUsers.containsKey(participantId)) {
threadParticipantUsers[participantId] = participant
}
}
val username = participant.getString("username")
if (username != null && username != currentUsername && !threadParticipantUsernames.contains(username)) {
threadParticipantUsernames.add(username)
}
}
}
}
}
val existingUserIds = queryIds(db, "User", userIds.toTypedArray())
val existingUsernames = queryByColumn(db, "User", "username", usernames.toTypedArray())
userIds.removeAll { it in existingUserIds }
usernames.removeAll { it in existingUsernames }
if (threadParticipantUserIds.size > 0) {
// Do not fetch users found in thread participants as we get the user's data in the posts response already
userIds.removeAll { it in threadParticipantUserIds }
usernames.removeAll { it in threadParticipantUsernames }
// Get users from thread participants
val existingThreadParticipantUserIds = queryIds(db, "User", threadParticipantUserIds.toTypedArray())
// Exclude the thread participants already present in the DB from getting inserted again
val usersFromThreads = WritableNativeArray()
threadParticipantUsers.forEach { (userId, user) ->
if (!existingThreadParticipantUserIds.contains(userId)) {
usersFromThreads.pushMap(user)
}
}
if (usersFromThreads.size() > 0) {
results.putArray("usersFromThreads", usersFromThreads)
}
}
if (userIds.size > 0) {
results.putArray("userIdsToLoad", ReadableArrayUtils.toWritableArray(userIds.toTypedArray()))
}
if (usernames.size > 0) {
results.putArray("usernamesToLoad", ReadableArrayUtils.toWritableArray(usernames.toTypedArray()))
}
if (threads.size() > 0) {
results.putArray("threads", threads)
}
}
}
}
results
} catch (e: Exception) {
e.printStackTrace()
null
}
}

View File

@@ -0,0 +1,28 @@
package com.mattermost.helpers.push_notification
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
suspend fun PushNotificationDataRunnable.Companion.fetchTeamIfNeeded(db: Database, serverUrl: String, teamId: String): Pair<ReadableMap?, ReadableMap?> {
return try {
var team: ReadableMap? = null
var myTeam: ReadableMap? = null
val teamExists = findTeam(db, teamId)
val myTeamExists = findMyTeam(db, teamId)
if (!teamExists) {
team = fetch(serverUrl, "/api/v4/teams/$teamId")
}
if (!myTeamExists) {
myTeam = fetch(serverUrl, "/api/v4/teams/$teamId/members/me")
}
Pair(team, myTeam)
} catch (e: Exception) {
e.printStackTrace()
Pair(null, null)
}
}

View File

@@ -0,0 +1,19 @@
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
internal suspend fun PushNotificationDataRunnable.Companion.fetchThread(db: Database, serverUrl: String, threadId: String, teamId: String?): ReadableMap? {
val currentUserId = queryCurrentUserId(db) ?: return null
val threadTeamId = (if (teamId.isNullOrEmpty()) queryCurrentTeamId(db) else teamId) ?: return null
return try {
val thread = fetch(serverUrl, "/api/v4/users/$currentUserId/teams/${threadTeamId}/threads/$threadId")
thread?.getMap("data")
} catch (e: Exception) {
e.printStackTrace()
null
}
}

View File

@@ -0,0 +1,61 @@
package com.mattermost.helpers.push_notification
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.PushNotificationDataRunnable
import com.mattermost.helpers.ReadableArrayUtils
internal suspend fun PushNotificationDataRunnable.Companion.fetchUsersById(serverUrl: String, userIds: ReadableArray): ReadableArray? {
return try {
val endpoint = "api/v4/users/ids"
val options = Arguments.createMap()
options.putArray("body", ReadableArrayUtils.toWritableArray(ReadableArrayUtils.toArray(userIds)))
val result = fetchWithPost(serverUrl, endpoint, options)
result?.getArray("data")
} catch (e: Exception) {
e.printStackTrace()
null
}
}
internal suspend fun PushNotificationDataRunnable.Companion.fetchUsersByUsernames(serverUrl: String, usernames: ReadableArray): ReadableArray? {
return try {
val endpoint = "api/v4/users/usernames"
val options = Arguments.createMap()
options.putArray("body", ReadableArrayUtils.toWritableArray(ReadableArrayUtils.toArray(usernames)))
val result = fetchWithPost(serverUrl, endpoint, options)
result?.getArray("data")
} catch (e: Exception) {
e.printStackTrace()
null
}
}
internal suspend fun PushNotificationDataRunnable.Companion.fetchNeededUsers(serverUrl: String, loadedUsers: ReadableArray?, data: ReadableMap?): ArrayList<Any> {
val userList = ArrayList<Any>()
loadedUsers?.let { PushNotificationDataRunnable.addUsersToList(it, userList) }
data?.getArray("userIdsToLoad")?.let { ids ->
if (ids.size() > 0) {
val result = fetchUsersById(serverUrl, ids)
result?.let { PushNotificationDataRunnable.addUsersToList(it, userList) }
}
}
data?.getArray("usernamesToLoad")?.let { ids ->
if (ids.size() > 0) {
val result = fetchUsersByUsernames(serverUrl, ids)
result?.let { PushNotificationDataRunnable.addUsersToList(it, userList) }
}
}
data?.getArray("usersFromThreads")?.let { PushNotificationDataRunnable.addUsersToList(it, userList) }
return userList
}
internal fun PushNotificationDataRunnable.Companion.addUsersToList(users: ReadableArray, list: ArrayList<Any>) {
for (i in 0 until users.size()) {
list.add(users.getMap(i))
}
}

View File

@@ -7,23 +7,29 @@ import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import java.util.Objects;
import com.facebook.react.bridge.ReadableMap;
import com.mattermost.helpers.CustomPushNotificationHelper;
import com.mattermost.helpers.DatabaseHelper;
import com.mattermost.helpers.Network;
import com.mattermost.helpers.NotificationHelper;
import com.mattermost.helpers.PushNotificationDataHelper;
import com.mattermost.helpers.ReadableMapUtils;
import com.mattermost.share.ShareModule;
import com.wix.reactnativenotifications.core.NotificationIntentAdapter;
import com.wix.reactnativenotifications.core.notification.PushNotification;
import com.wix.reactnativenotifications.core.AppLaunchHelper;
import com.wix.reactnativenotifications.core.AppLifecycleFacade;
import com.wix.reactnativenotifications.core.JsIOHelper;
import static com.mattermost.helpers.database_extension.GeneralKt.*;
import static com.wix.reactnativenotifications.Defs.NOTIFICATION_RECEIVED_EVENT_NAME;
public class CustomPushNotification extends PushNotification {
private final PushNotificationDataHelper dataHelper;
@@ -51,7 +57,6 @@ public class CustomPushNotification extends PushNotification {
int notificationId = NotificationHelper.getNotificationId(initialData);
String serverUrl = addServerUrlToBundle(initialData);
boolean isReactInit = mAppLifecycleFacade.isReactInitialized();
if (ackId != null && serverUrl != null) {
Bundle response = ReceiptDelivery.send(ackId, serverUrl, postId, type, isIdLoaded);
@@ -65,7 +70,7 @@ public class CustomPushNotification extends PushNotification {
}
}
finishProcessingNotification(serverUrl, type, channelId, notificationId, isReactInit);
finishProcessingNotification(serverUrl, type, channelId, notificationId);
}
@Override
@@ -78,7 +83,9 @@ public class CustomPushNotification extends PushNotification {
}
}
private void finishProcessingNotification(String serverUrl, String type, String channelId, int notificationId, Boolean isReactInit) {
private void finishProcessingNotification(final String serverUrl, @NonNull final String type, final String channelId, final int notificationId) {
final boolean isReactInit = mAppLifecycleFacade.isReactInitialized();
switch (type) {
case CustomPushNotificationHelper.PUSH_TYPE_MESSAGE:
case CustomPushNotificationHelper.PUSH_TYPE_SESSION:
@@ -90,13 +97,17 @@ public class CustomPushNotification extends PushNotification {
if (type.equals(CustomPushNotificationHelper.PUSH_TYPE_MESSAGE)) {
if (channelId != null) {
Bundle notificationBundle = mNotificationProps.asBundle();
if (serverUrl != null && !isReactInit) {
if (serverUrl != null) {
// We will only fetch the data related to the notification on the native side
// as updating the data directly to the db removes the wal & shm files needed
// by watermelonDB, if the DB is updated while WDB is running it causes WDB to
// detect the database as malformed, thus the app stop working and a restart is required.
// Data will be fetch from within the JS context instead.
dataHelper.fetchAndStoreDataForPushNotification(notificationBundle);
Bundle notificationResult = dataHelper.fetchAndStoreDataForPushNotification(notificationBundle, isReactInit);
if (notificationResult != null) {
notificationBundle.putBundle("data", notificationResult);
mNotificationProps = createProps(notificationBundle);
}
}
createSummary = NotificationHelper.addNotificationToPreferences(
mContext,
@@ -145,17 +156,20 @@ public class CustomPushNotification extends PushNotification {
}
private String addServerUrlToBundle(Bundle bundle) {
DatabaseHelper dbHelper = DatabaseHelper.Companion.getInstance();
String serverId = bundle.getString("server_id");
String serverUrl;
if (serverId == null) {
serverUrl = Objects.requireNonNull(DatabaseHelper.Companion.getInstance()).getOnlyServerUrl();
} else {
serverUrl = Objects.requireNonNull(DatabaseHelper.Companion.getInstance()).getServerUrlForIdentifier(serverId);
}
String serverUrl = null;
if (dbHelper != null) {
if (serverId == null) {
serverUrl = dbHelper.getOnlyServerUrl();
} else {
serverUrl = getServerUrlForIdentifier(dbHelper, serverId);
}
if (!TextUtils.isEmpty(serverUrl)) {
bundle.putString("server_url", serverUrl);
mNotificationProps = createProps(bundle);
if (!TextUtils.isEmpty(serverUrl)) {
bundle.putString("server_url", serverUrl);
mNotificationProps = createProps(bundle);
}
}
return serverUrl;

View File

@@ -13,12 +13,12 @@ class FoldableObserver(private val activity: Activity) {
private var disposable: Disposable? = null
private lateinit var observable: Observable<WindowLayoutInfo>
public fun onCreate() {
fun onCreate() {
observable = WindowInfoTracker.getOrCreate(activity)
.windowLayoutInfoObservable(activity)
}
public fun onStart() {
fun onStart() {
if (disposable?.isDisposed == true) {
onCreate()
}
@@ -42,7 +42,7 @@ class FoldableObserver(private val activity: Activity) {
}
}
public fun onStop() {
fun onStop() {
disposable?.dispose()
}

View File

@@ -1,6 +1,8 @@
package com.mattermost.rnbeta;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.view.KeyEvent;
@@ -12,9 +14,11 @@ import com.github.emilioicai.hwkeyboardevent.HWKeyboardEventModule;
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
import com.facebook.react.defaults.DefaultReactActivityDelegate;
import java.util.Objects;
public class MainActivity extends NavigationActivity {
private boolean HWKeyboardConnected = false;
private FoldableObserver foldableObserver = new FoldableObserver(this);
private final FoldableObserver foldableObserver = new FoldableObserver(this);
@Override
protected String getMainComponentName() {
@@ -30,7 +34,7 @@ public class MainActivity extends NavigationActivity {
protected ReactActivityDelegate createReactActivityDelegate() {
return new DefaultReactActivityDelegate(
this,
getMainComponentName(),
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).
@@ -59,7 +63,7 @@ public class MainActivity extends NavigationActivity {
}
@Override
public void onConfigurationChanged(Configuration newConfig) {
public void onConfigurationChanged(@NonNull Configuration newConfig) {
super.onConfigurationChanged(newConfig);
if (newConfig.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_NO) {
@@ -97,7 +101,7 @@ public class MainActivity extends NavigationActivity {
}
}
return super.dispatchKeyEvent(event);
};
}
private void setHWKeyboardConnected() {
HWKeyboardConnected = getResources().getConfiguration().keyboard == Configuration.KEYBOARD_QWERTY;

View File

@@ -32,10 +32,10 @@ import com.mattermost.helpers.RealPathUtil;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.net.URL;
import java.nio.channels.FileChannel;
import java.util.Objects;
public class MattermostManagedModule extends ReactContextBaseJavaModule {
private static final String SAVE_EVENT = "MattermostManagedSaveFile";
@@ -46,8 +46,6 @@ public class MattermostManagedModule extends ReactContextBaseJavaModule {
private Promise mPickerPromise;
private String fileContent;
private static final String TAG = MattermostManagedModule.class.getSimpleName();
private MattermostManagedModule(ReactApplicationContext reactContext) {
super(reactContext);
this.reactContext = reactContext;
@@ -149,7 +147,7 @@ public class MattermostManagedModule extends ReactContextBaseJavaModule {
}
try {
final String packageName = currentActivity.getPackageName();
final String authority = new StringBuilder(packageName).append(".provider").toString();
final String authority = packageName + ".provider";
contentUri = FileProvider.getUriForFile(currentActivity, authority, newFile);
}
catch(IllegalArgumentException e) {
@@ -176,7 +174,7 @@ public class MattermostManagedModule extends ReactContextBaseJavaModule {
intent.setType(mimeType);
intent.putExtra(Intent.EXTRA_TITLE, filename);
PackageManager pm = getCurrentActivity().getPackageManager();
PackageManager pm = Objects.requireNonNull(getCurrentActivity()).getPackageManager();
if (intent.resolveActivity(pm) != null) {
try {
getCurrentActivity().startActivityForResult(intent, SAVE_REQUEST);
@@ -211,7 +209,7 @@ public class MattermostManagedModule extends ReactContextBaseJavaModule {
if (!TextUtils.isEmpty(token)) {
WritableMap headers = Arguments.createMap();
if (optionsMap.hasKey("headers")) {
headers.merge(optionsMap.getMap("headers"));
headers.merge(Objects.requireNonNull(optionsMap.getMap("headers")));
}
headers.putString("Authorization", "Bearer " + token);
optionsMap.putMap("headers", headers);
@@ -237,34 +235,21 @@ public class MattermostManagedModule extends ReactContextBaseJavaModule {
@Override
protected Object doInBackgroundGuarded() {
FileChannel source = null;
FileChannel dest = null;
try {
File input = new File(this.fromFile);
FileInputStream fileInputStream = new FileInputStream(input);
ParcelFileDescriptor pfd = weakContext.get().getContentResolver().openFileDescriptor(toFile, "w");
FileOutputStream fileOutputStream = new FileOutputStream(pfd.getFileDescriptor());
source = fileInputStream.getChannel();
dest = fileOutputStream.getChannel();
dest.transferFrom(source, 0, source.size());
File input = new File(this.fromFile);
try (FileInputStream fileInputStream = new FileInputStream(input)) {
try (FileOutputStream fileOutputStream = new FileOutputStream(pfd.getFileDescriptor())) {
FileChannel source = fileInputStream.getChannel();
FileChannel dest = fileOutputStream.getChannel();
dest.transferFrom(source, 0, source.size());
source.close();
dest.close();
}
}
pfd.close();
} catch (Exception e) {
e.printStackTrace();
} finally {
if (source != null) {
try {
source.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (dest != null) {
try {
dest.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return null;

View File

@@ -1,7 +1,6 @@
package com.mattermost.rnbeta;
import android.app.Notification;
import android.app.NotificationManager;
import android.content.Context;
import android.os.Bundle;
import android.service.notification.StatusBarNotification;

View File

@@ -29,11 +29,10 @@ class SplitViewModule(private var reactContext: ReactApplicationContext) : React
override fun getName() = "SplitView"
fun sendEvent(eventName: String,
params: WritableMap?) {
private fun sendEvent(params: WritableMap?) {
reactContext
.getJSModule(RCTDeviceEventEmitter::class.java)
.emit(eventName, params)
.emit("SplitViewChanged", params)
}
private fun getSplitViewResults(folded: Boolean) : WritableMap? {
@@ -51,7 +50,7 @@ class SplitViewModule(private var reactContext: ReactApplicationContext) : React
fun setDeviceFolded(folded: Boolean) {
val map = getSplitViewResults(folded)
if (listenerCount > 0 && isDeviceFolded != folded) {
sendEvent("SplitViewChanged", map)
sendEvent(map)
}
isDeviceFolded = folded
}

View File

@@ -29,6 +29,7 @@ import org.json.JSONException;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
@@ -75,8 +76,8 @@ public class ShareModule extends ReactContextBaseJavaModule {
public String getCurrentActivityName() {
Activity currentActivity = getCurrentActivity();
if (currentActivity != null) {
String actvName = currentActivity.getComponentName().getClassName();
String[] components = actvName.split("\\.");
String activityName = currentActivity.getComponentName().getClassName();
String[] components = activityName.split("\\.");
return components[components.length - 1];
}
@@ -115,7 +116,7 @@ public class ShareModule extends ReactContextBaseJavaModule {
if (data != null && data.hasKey("serverUrl")) {
ReadableArray files = data.getArray("files");
String serverUrl = data.getString("serverUrl");
final String token = Credentials.getCredentialsForServerSync(this.getReactApplicationContext(), serverUrl);
final String token = Credentials.getCredentialsForServerSync(mReactContext, serverUrl);
JSONObject postData = buildPostObject(data);
if (files != null && files.size() > 0) {
@@ -236,7 +237,7 @@ public class ShareModule extends ReactContextBaseJavaModule {
try (Response response = client.newCall(request).execute()) {
if (response.isSuccessful()) {
String responseData = response.body().string();
String responseData = Objects.requireNonNull(response.body()).string();
JSONObject responseJson = new JSONObject(responseData);
JSONArray fileInfoArray = responseJson.getJSONArray("file_infos");
JSONArray file_ids = new JSONArray();

View File

@@ -7,9 +7,9 @@ buildscript {
compileSdkVersion = 33
targetSdkVersion = 33
supportLibVersion = "33.0.0"
kotlinVersion = "1.5.30"
kotlin_version = "1.5.30"
firebaseVersion = "21.0.0"
kotlinVersion = "1.7.21"
kotlin_version = "1.7.21"
firebaseVersion = "23.1.1"
RNNKotlinVersion = kotlinVersion
// We use NDK 23 which has both M1 support and is the side-by-side NDK version from AGP.
@@ -23,7 +23,7 @@ buildscript {
dependencies {
classpath("com.android.tools.build:gradle:7.3.1")
classpath("com.facebook.react:react-native-gradle-plugin")
classpath('com.google.gms:google-services:4.3.14')
classpath('com.google.gms:google-services:4.3.15')
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
// NOTE: Do not place your application dependencies here; they belong

View File

@@ -29,7 +29,7 @@ android.useAndroidX=true
android.enableJetifier=true
# Version of flipper SDK to use with React Native
FLIPPER_VERSION=0.125.0
FLIPPER_VERSION=0.177.0
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using

View File

@@ -5,6 +5,8 @@ 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')

View File

@@ -5,7 +5,7 @@
import {DeviceEventEmitter} from 'react-native';
import {addChannelToDefaultCategory, storeCategories} from '@actions/local/category';
import {removeCurrentUserFromChannel, setChannelDeleteAt, storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel';
import {markChannelAsViewed, removeCurrentUserFromChannel, setChannelDeleteAt, storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel';
import {switchToGlobalThreads} from '@actions/local/thread';
import {updateLocalUser} from '@actions/local/user';
import {loadCallForChannel} from '@calls/actions/calls';
@@ -726,11 +726,15 @@ export async function joinChannelIfNeeded(serverUrl: string, channelId: string)
}
}
export async function markChannelAsRead(serverUrl: string, channelId: string) {
export async function markChannelAsRead(serverUrl: string, channelId: string, updateLocal = false) {
try {
const client = NetworkManager.getClient(serverUrl);
await client.viewMyChannel(channelId);
if (updateLocal) {
await markChannelAsViewed(serverUrl, channelId, true);
}
return {};
} catch (error) {
return {error};

View File

@@ -1,8 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {markChannelAsViewed} from '@actions/local/channel';
import {dataRetentionCleanup} from '@actions/local/systems';
import {fetchMissingDirectChannelsInfo, fetchMyChannelsForTeam, handleKickFromChannel, MyChannelsRequest} from '@actions/remote/channel';
import {fetchMissingDirectChannelsInfo, fetchMyChannelsForTeam, handleKickFromChannel, markChannelAsRead, MyChannelsRequest} from '@actions/remote/channel';
import {fetchGroupsForMember} from '@actions/remote/groups';
import {fetchPostsForUnreadChannels} from '@actions/remote/post';
import {MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference';
@@ -560,6 +561,9 @@ export async function handleEntryAfterLoadNavigation(
} else {
await setCurrentTeamAndChannelId(operator, initialTeamId, initialChannelId);
}
} else if (tabletDevice && initialChannelId === currentChannelId) {
await markChannelAsRead(serverUrl, initialChannelId);
markChannelAsViewed(serverUrl, initialChannelId);
}
} catch (error) {
logDebug('could not manage the entry after load navigation', error);

View File

@@ -3,19 +3,26 @@
import {Platform} from 'react-native';
// import {updatePostSinceCache, updatePostsInThreadsSinceCache} from '@actions/local/notification';
import {addChannelToDefaultCategory, storeCategories} from '@actions/local/category';
import {storeMyChannelsForTeam} from '@actions/local/channel';
import {storePostsForChannel} from '@actions/local/post';
import {fetchDirectChannelsInfo, fetchMyChannel, switchToChannelById} from '@actions/remote/channel';
import {fetchPostsForChannel, fetchPostThread} from '@actions/remote/post';
import {forceLogoutIfNecessary} from '@actions/remote/session';
import {fetchMyTeam} from '@actions/remote/team';
import {fetchAndSwitchToThread} from '@actions/remote/thread';
import {ActionType} from '@constants';
import DatabaseManager from '@database/manager';
import {getMyChannel, getChannelById} from '@queries/servers/channel';
import {getCurrentTeamId, getWebSocketLastDisconnected} from '@queries/servers/system';
import {getMyTeamById} from '@queries/servers/team';
import {getCurrentTeamId} from '@queries/servers/system';
import {getMyTeamById, prepareMyTeams} from '@queries/servers/team';
import {getIsCRTEnabled} from '@queries/servers/thread';
import EphemeralStore from '@store/ephemeral_store';
import {logWarning} from '@utils/log';
import {emitNotificationError} from '@utils/notification';
import {processPostsFetched} from '@utils/post';
import type {Model} from '@nozbe/watermelondb';
const fetchNotificationData = async (serverUrl: string, notification: NotificationWithData, skipEvents = false) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
@@ -95,24 +102,84 @@ const fetchNotificationData = async (serverUrl: string, notification: Notificati
};
export const backgroundNotification = async (serverUrl: string, notification: NotificationWithData) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return;
}
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const channelId = notification.payload?.channel_id;
let teamId = notification.payload?.team_id;
if (!channelId) {
throw new Error('No chanel Id was specified');
}
const lastDisconnectedAt = await getWebSocketLastDisconnected(database);
if (lastDisconnectedAt) {
// if (Platform.OS === 'ios') {
// const isCRTEnabled = await getIsCRTEnabled(database);
// const isThreadNotification = isCRTEnabled && Boolean(notification.payload?.root_id);
// if (isThreadNotification) {
// updatePostsInThreadsSinceCache(serverUrl, notification);
// } else {
// updatePostSinceCache(serverUrl, notification);
// }
// }
if (!teamId) {
// If the notification payload does not have a teamId we assume is a DM/GM
const currentTeamId = await getCurrentTeamId(database);
teamId = currentTeamId;
}
if (notification.payload?.data) {
const {data, isCRTEnabled} = notification.payload;
const {channel, myChannel, team, myTeam, posts, users, threads} = data;
const models: Model[] = [];
await fetchNotificationData(serverUrl, notification, true);
if (posts) {
const postsData = processPostsFetched(posts);
const isThreadNotification = isCRTEnabled && Boolean(notification.payload.root_id);
const actionType = isThreadNotification ? ActionType.POSTS.RECEIVED_IN_THREAD : ActionType.POSTS.RECEIVED_IN_CHANNEL;
if (team || myTeam) {
const teamPromises = prepareMyTeams(operator, team ? [team] : [], myTeam ? [myTeam] : []);
if (teamPromises.length) {
const teamModels = await Promise.all(teamPromises);
models.push(...teamModels.flat());
}
}
await storeMyChannelsForTeam(
serverUrl, teamId,
channel ? [channel] : [],
myChannel ? [myChannel] : [],
true, isCRTEnabled,
);
if (data.categoryChannels?.length && channel) {
const {models: categoryModels} = await addChannelToDefaultCategory(serverUrl, channel, true);
if (categoryModels?.length) {
models.push(...categoryModels);
}
} else if (data.categories?.categories) {
const {models: categoryModels} = await storeCategories(serverUrl, data.categories.categories, false, true);
if (categoryModels?.length) {
models.push(...categoryModels);
}
}
await storePostsForChannel(
serverUrl, channelId,
postsData.posts, postsData.order, postsData.previousPostId ?? '',
actionType, users || [],
);
if (isThreadNotification && threads?.length) {
const threadModels = await operator.handleThreads({
threads: threads.map((t) => ({
...t,
lastFetchedAt: Math.max(t.post.create_at, t.post.update_at, t.post.delete_at),
})),
teamId,
prepareRecordsOnly: true,
});
if (threadModels.length) {
models.push(...threadModels);
}
}
}
if (models.length) {
await operator.batchRecords(models, 'backgroundNotification');
}
}
} catch (error) {
logWarning('backgroundNotification', error);
}
};

View File

@@ -245,7 +245,7 @@ export const sendPasswordResetEmail = async (serverUrl: string, email: string) =
return {error};
}
return {
data: response.data,
status: response.status,
error: undefined,
};
};

View File

@@ -115,7 +115,7 @@ export const updateTeamThreadsAsRead = async (serverUrl: string, teamId: string)
}
};
export const markThreadAsRead = async (serverUrl: string, teamId: string | undefined, threadId: string) => {
export const markThreadAsRead = async (serverUrl: string, teamId: string | undefined, threadId: string, updateLastViewed = true) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
@@ -141,7 +141,7 @@ export const markThreadAsRead = async (serverUrl: string, teamId: string | undef
// Update locally
await updateThread(serverUrl, threadId, {
last_viewed_at: timestamp,
last_viewed_at: updateLastViewed ? timestamp : undefined,
unread_replies: 0,
unread_mentions: 0,
});

View File

@@ -6,7 +6,7 @@ import {DeviceEventEmitter} from 'react-native';
import {storeMyChannelsForTeam, markChannelAsUnread, markChannelAsViewed, updateLastPostAt} from '@actions/local/channel';
import {markPostAsDeleted} from '@actions/local/post';
import {createThreadFromNewPost, updateThread} from '@actions/local/thread';
import {fetchChannelStats, fetchMyChannel, markChannelAsRead} from '@actions/remote/channel';
import {fetchChannelStats, fetchMyChannel} from '@actions/remote/channel';
import {fetchPostAuthors, fetchPostById} from '@actions/remote/post';
import {fetchThread} from '@actions/remote/thread';
import {ActionType, Events, Screens} from '@constants';
@@ -116,7 +116,6 @@ export async function handleNewPostEvent(serverUrl: string, msg: WebSocketMessag
if (!shouldIgnorePost(post)) {
let markAsViewed = false;
let markAsRead = false;
if (!myChannel.manuallyUnread) {
if (
@@ -125,21 +124,17 @@ export async function handleNewPostEvent(serverUrl: string, msg: WebSocketMessag
!isFromWebhook(post)
) {
markAsViewed = true;
markAsRead = false;
} else if ((post.channel_id === currentChannelId)) {
const isChannelScreenMounted = NavigationStore.getScreensInStack().includes(Screens.CHANNEL);
const isTabletDevice = await isTablet();
if (isChannelScreenMounted || isTabletDevice) {
markAsViewed = false;
markAsRead = true;
}
}
}
if (markAsRead) {
markChannelAsRead(serverUrl, post.channel_id);
} else if (markAsViewed) {
if (markAsViewed) {
preparedMyChannelHack(myChannel);
const {member: viewedAt} = await markChannelAsViewed(serverUrl, post.channel_id, false, true);
if (viewedAt) {

View File

@@ -37,6 +37,7 @@ type Props = {
const getStyle = makeStyleSheetFromTheme((theme: Theme) => ({
background: {
backgroundColor: theme.sidebarBg,
zIndex: 1,
},
bannerContainer: {
flex: 1,

View File

@@ -36,6 +36,7 @@ const getStyle = makeStyleSheetFromTheme((theme: Theme) => {
return {
background: {
backgroundColor: theme.sidebarBg,
zIndex: 1,
},
bannerContainerNotConnected: {
...bannerContainer,

View File

@@ -134,12 +134,8 @@ export default function PostInput({
return {...style.input, maxHeight};
}, [maxHeight, style.input]);
const blur = () => {
inputRef.current?.blur();
};
const handleAndroidKeyboard = () => {
blur();
onBlur();
};
const onBlur = useCallback(() => {

View File

@@ -25,19 +25,13 @@ const hitSlop = {top: 10, bottom: 10, left: 10, right: 10};
const TouchableEmoji = ({category, name, onEmojiPress, size = 30, style}: Props) => {
const onPress = useCallback(preventDoubleTap(() => onEmojiPress(name)), []);
let emoji;
if (category && CATEGORIES_WITH_SKINS.includes(category)) {
emoji = (
return (
<SkinnedEmoji
name={name}
onEmojiPress={onEmojiPress}
size={size}
/>
);
} else {
emoji = (
<Emoji
emojiName={name}
size={size}
style={style}
/>
);
}
@@ -52,7 +46,10 @@ const TouchableEmoji = ({category, name, onEmojiPress, size = 30, style}: Props)
style={style}
type={'opacity'}
>
{emoji}
<Emoji
emojiName={name}
size={size}
/>
</TouchableWithFeedback>
</View>
);

View File

@@ -1,19 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import React, {useCallback, useMemo} from 'react';
import {StyleProp, View, ViewStyle} from 'react-native';
import {useEmojiSkinTone} from '@app/hooks/emoji_category_bar';
import {preventDoubleTap} from '@app/utils/tap';
import Emoji from '@components/emoji';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {skinCodes} from '@utils/emoji';
import {isValidNamedEmoji} from '@utils/emoji/helpers';
type Props = {
name: string;
onEmojiPress: (emoji: string) => void;
size?: number;
style?: StyleProp<ViewStyle>;
}
const SkinnedEmoji = ({name, size = 30}: Props) => {
const hitSlop = {top: 10, bottom: 10, left: 10, right: 10};
const SkinnedEmoji = ({name, onEmojiPress, size = 30, style}: Props) => {
const skinTone = useEmojiSkinTone();
const emojiName = useMemo(() => {
const skinnedEmoji = `${name}_${skinCodes[skinTone]}`;
@@ -23,11 +30,26 @@ const SkinnedEmoji = ({name, size = 30}: Props) => {
return skinnedEmoji;
}, [name, skinTone]);
const onPress = useCallback(preventDoubleTap(() => {
onEmojiPress(emojiName);
}), [emojiName]);
return (
<Emoji
emojiName={emojiName}
size={size}
/>
<View
style={style}
>
<TouchableWithFeedback
hitSlop={hitSlop}
onPress={onPress}
style={style}
type={'opacity'}
>
<Emoji
emojiName={emojiName}
size={size}
/>
</TouchableWithFeedback>
</View>
);
};

View File

@@ -318,12 +318,12 @@ const ChannelHandler = <TBase extends Constructor<ServerDataOperatorBase>>(super
}));
const uniqueRaws = getUniqueRawsBy({raws: memberships, key: 'id'}) as ChannelMember[];
const ids = uniqueRaws.map((cm: ChannelMember) => cm.channel_id);
const ids = uniqueRaws.map((cm: ChannelMember) => `${cm.channel_id}-${cm.user_id}`);
const db: Database = this.database;
const existing = await db.get<ChannelMembershipModel>(CHANNEL_MEMBERSHIP).query(
Q.where('id', Q.oneOf(ids)),
).fetch();
const membershipMap = new Map<string, ChannelMembershipModel>(existing.map((member) => [member.id, member]));
const membershipMap = new Map<string, ChannelMembershipModel>(existing.map((member) => [member.channelId, member]));
const createOrUpdateRawValues = uniqueRaws.reduce((res: ChannelMember[], cm) => {
const e = membershipMap.get(cm.channel_id);
if (!e) {

View File

@@ -148,13 +148,15 @@ const GroupHandler = <TBase extends Constructor<ServerDataOperatorBase>>(supercl
rawValues.push(...Object.values(groupsSet));
}
records.push(...(await this.handleRecords({
fieldName: 'id',
transformer: transformGroupMembershipRecord,
createOrUpdateRawValues: rawValues,
tableName: GROUP_MEMBERSHIP,
prepareRecordsOnly: true,
}, 'handleGroupMembershipsForMember')));
if (rawValues.length) {
records.push(...(await this.handleRecords({
fieldName: 'id',
transformer: transformGroupMembershipRecord,
createOrUpdateRawValues: rawValues,
tableName: GROUP_MEMBERSHIP,
prepareRecordsOnly: true,
}, 'handleGroupMembershipsForMember')));
}
// Batch update if there are records
if (records.length && !prepareRecordsOnly) {

View File

@@ -164,11 +164,11 @@ describe('*** Operator: Thread Handlers tests ***', () => {
expect(spyOnPrepareRecords).toHaveBeenCalledWith({
createRaws: [{
raw: {team_id: 'team_id_1', thread_id: 'thread-1'},
}, {
raw: {team_id: 'team_id_1', thread_id: 'thread-2'},
record: undefined,
}, {
raw: {team_id: 'team_id_2', thread_id: 'thread-2'},
record: undefined,
}],
transformer: transformThreadInTeamRecord,
tableName: 'ThreadsInTeam',

View File

@@ -124,7 +124,9 @@ const ThreadHandler = <TBase extends Constructor<ServerDataOperatorBase>>(superc
threadsMap: {[teamId]: threads},
prepareRecordsOnly: true,
}) as ThreadInTeamModel[];
batch.push(...threadsInTeam);
if (threadsInTeam.length) {
batch.push(...threadsInTeam);
}
}
if (batch.length && !prepareRecordsOnly) {
@@ -199,7 +201,7 @@ const ThreadHandler = <TBase extends Constructor<ServerDataOperatorBase>>(superc
const threadIds = threadsMap[teamId].map((thread) => thread.id);
const chunks = await (this.database as Database).get<ThreadInTeamModel>(THREADS_IN_TEAM).query(
Q.where('team_id', teamId),
Q.where('id', Q.oneOf(threadIds)),
Q.where('thread_id', Q.oneOf(threadIds)),
).fetch();
const chunksMap = chunks.reduce((result: Record<string, ThreadInTeamModel>, chunk) => {
result[chunk.threadId] = chunk;

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {sanitizeLikeString} from '.';
describe('Test SQLite Sanitize like string with latin and non-latin characters', () => {
const disallowed = ',./;[]!@#$%^&*()_-=+~';
test('test (latin)', () => {
expect(sanitizeLikeString('test123')).toBe('test123');
expect(sanitizeLikeString(`test123${disallowed}`)).toBe(`test123${'_'.repeat(disallowed.length)}`);
});
test('test (arabic)', () => {
expect(sanitizeLikeString('اختبار123')).toBe('اختبار123');
expect(sanitizeLikeString(`اختبار123${disallowed}`)).toBe(`اختبار123${'_'.repeat(disallowed.length)}`);
});
test('test (greek)', () => {
expect(sanitizeLikeString(οκιμή123')).toBe(οκιμή123');
expect(sanitizeLikeString(`δοκιμή123${disallowed}`)).toBe(`δοκιμή123${'_'.repeat(disallowed.length)}`);
});
test('test (hebrew)', () => {
expect(sanitizeLikeString('חשבון123')).toBe('חשבון123');
expect(sanitizeLikeString(`חשבון123${disallowed}`)).toBe(`חשבון123${'_'.repeat(disallowed.length)}`);
});
test('test (russian)', () => {
expect(sanitizeLikeString(ест123')).toBe(ест123');
expect(sanitizeLikeString(`тест123${disallowed}`)).toBe(`тест123${'_'.repeat(disallowed.length)}`);
});
test('test (chinese trad)', () => {
expect(sanitizeLikeString('測試123')).toBe('測試123');
expect(sanitizeLikeString(`測試123${disallowed}`)).toBe(`測試123${'_'.repeat(disallowed.length)}`);
});
test('test (japanese)', () => {
expect(sanitizeLikeString('テスト123')).toBe('テスト123');
expect(sanitizeLikeString(`テスト123${disallowed}`)).toBe(`テスト123${'_'.repeat(disallowed.length)}`);
});
});

View File

@@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import xRegExp from 'xregexp';
import {General} from '@constants';
import type Model from '@nozbe/watermelondb/Model';
@@ -91,3 +93,7 @@ export const filterAndSortMyChannels = ([myChannels, channels, notifyProps]: Fil
return [...mentions, ...unreads, ...mutedMentions];
};
// Matches letters from any alphabet and numbers
const sqliteLikeStringRegex = xRegExp('[^\\p{L}\\p{Nd}]', 'g');
export const sanitizeLikeString = (value: string) => value.replace(sqliteLikeStringRegex, '_');

View File

@@ -9,6 +9,7 @@ import {map as map$, switchMap, distinctUntilChanged} from 'rxjs/operators';
import {General, Permissions} from '@constants';
import {MM_TABLES} from '@constants/database';
import {sanitizeLikeString} from '@helpers/database';
import {hasPermission} from '@utils/role';
import {prepareDeletePost} from './post';
@@ -17,6 +18,7 @@ import {observeCurrentChannelId, getCurrentChannelId, observeCurrentUserId} from
import {observeTeammateNameDisplay} from './user';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type {Clause} from '@nozbe/watermelondb/QueryDescription';
import type ChannelModel from '@typings/database/models/servers/channel';
import type ChannelInfoModel from '@typings/database/models/servers/channel_info';
import type ChannelMembershipModel from '@typings/database/models/servers/channel_membership';
@@ -225,7 +227,12 @@ export const observeChannel = (database: Database, channelId: string) => {
};
export const getChannelByName = async (database: Database, teamId: string, channelName: string) => {
const channels = await database.get<ChannelModel>(CHANNEL).query(Q.on(TEAM, 'id', teamId), Q.where('name', channelName)).fetch();
const clauses: Clause[] = [];
if (teamId) {
clauses.push(Q.on(TEAM, 'id', teamId));
}
clauses.push(Q.where('name', channelName));
const channels = await database.get<ChannelModel>(CHANNEL).query(...clauses).fetch();
// Check done to force types
if (channels.length) {
@@ -454,6 +461,29 @@ export const queryEmptyDirectAndGroupChannels = (database: Database) => {
);
};
export const observeArchivedDirectChannels = (database: Database, currentUserId: string) => {
const deactivatedIds = database.get<UserModel>(USER).query(
Q.where('delete_at', Q.gt(0)),
).observe().pipe(
switchMap((users) => of$(users.map((u) => u.id))),
);
return deactivatedIds.pipe(
switchMap((dIds) => {
return database.get<ChannelModel>(CHANNEL).query(
Q.on(
CHANNEL_MEMBERSHIP,
Q.and(
Q.where('user_id', Q.notEq(currentUserId)),
Q.where('user_id', Q.oneOf(dIds)),
),
),
Q.where('type', 'D'),
).observe();
}),
);
};
export function observeMyChannelMentionCount(database: Database, teamId?: string, columns = ['mentions_count', 'is_unread']): Observable<number> {
const conditions: Q.Where[] = [
Q.where('delete_at', Q.eq(0)),
@@ -494,7 +524,7 @@ export function queryMyRecentChannels(database: Database, take: number) {
export const observeDirectChannelsByTerm = (database: Database, term: string, take = 20, matchStart = false) => {
const onlyDMs = term.startsWith('@') ? "AND c.type='D'" : '';
const value = Q.sanitizeLikeString(term.startsWith('@') ? term.substring(1) : term);
const value = sanitizeLikeString(term.startsWith('@') ? term.substring(1) : term);
let username = `u.username LIKE '${value}%'`;
let displayname = `c.display_name LIKE '${value}%'`;
if (!matchStart) {
@@ -520,7 +550,7 @@ export const observeDirectChannelsByTerm = (database: Database, term: string, ta
export const observeNotDirectChannelsByTerm = (database: Database, term: string, take = 20, matchStart = false) => {
const teammateNameSetting = observeTeammateNameDisplay(database);
const value = Q.sanitizeLikeString(term.startsWith('@') ? term.substring(1) : term);
const value = sanitizeLikeString(term.startsWith('@') ? term.substring(1) : term);
let username = `u.username LIKE '${value}%'`;
let nickname = `u.nickname LIKE '${value}%'`;
let displayname = `(u.first_name || ' ' || u.last_name) LIKE '${value}%'`;
@@ -561,7 +591,7 @@ export const observeJoinedChannelsByTerm = (database: Database, term: string, ta
return of$([]);
}
const value = Q.sanitizeLikeString(term);
const value = sanitizeLikeString(term);
let displayname = `c.display_name LIKE '${value}%'`;
if (!matchStart) {
displayname = `c.display_name LIKE '%${value}%' AND c.display_name NOT LIKE '${value}%'`;
@@ -579,7 +609,7 @@ export const observeArchiveChannelsByTerm = (database: Database, term: string, t
return of$([]);
}
const value = Q.sanitizeLikeString(term);
const value = sanitizeLikeString(term);
const displayname = `%${value}%`;
return database.get<MyChannelModel>(MY_CHANNEL).query(
Q.on(CHANNEL, Q.and(
@@ -610,7 +640,7 @@ export const observeChannelsByLastPostAt = (database: Database, myChannels: MyCh
};
export const queryChannelsForAutocomplete = (database: Database, matchTerm: string, isSearch: boolean, teamId: string) => {
const likeTerm = `%${Q.sanitizeLikeString(matchTerm)}%`;
const likeTerm = `%${sanitizeLikeString(matchTerm)}%`;
const clauses: Q.Clause[] = [];
if (isSearch) {
clauses.push(

View File

@@ -4,6 +4,7 @@
import {Database, Q} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database';
import {sanitizeLikeString} from '@helpers/database';
import type GroupModel from '@typings/database/models/servers/group';
import type GroupChannelModel from '@typings/database/models/servers/group_channel';
@@ -14,7 +15,7 @@ const {SERVER: {GROUP, GROUP_CHANNEL, GROUP_MEMBERSHIP, GROUP_TEAM}} = MM_TABLES
export const queryGroupsByName = (database: Database, name: string) => {
return database.collections.get<GroupModel>(GROUP).query(
Q.where('name', Q.like(`%${Q.sanitizeLikeString(name)}%`)),
Q.where('name', Q.like(`%${sanitizeLikeString(name)}%`)),
);
};
@@ -27,14 +28,14 @@ export const queryGroupsByNames = (database: Database, names: string[]) => {
export const queryGroupsByNameInTeam = (database: Database, name: string, teamId: string) => {
return database.collections.get<GroupModel>(GROUP).query(
Q.on(GROUP_TEAM, 'team_id', teamId),
Q.where('name', Q.like(`%${Q.sanitizeLikeString(name)}%`)),
Q.where('name', Q.like(`%${sanitizeLikeString(name)}%`)),
);
};
export const queryGroupsByNameInChannel = (database: Database, name: string, channelId: string) => {
return database.collections.get<GroupModel>(GROUP).query(
Q.on(GROUP_CHANNEL, 'channel_id', channelId),
Q.where('name', Q.like(`%${Q.sanitizeLikeString(name)}%`)),
Q.where('name', Q.like(`%${sanitizeLikeString(name)}%`)),
);
};

View File

@@ -7,6 +7,7 @@ import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import {MM_TABLES} from '@constants/database';
import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
import {sanitizeLikeString} from '@helpers/database';
import {queryDisplayNamePreferences} from './preference';
import {observeCurrentUserId, observeLicense, getCurrentUserId, getConfig, getLicense, observeConfigValue} from './system';
@@ -86,7 +87,7 @@ export async function getTeammateNameDisplay(database: Database) {
export const queryUsersLike = (database: Database, likeUsername: string) => {
return database.get<UserModel>(USER).query(
Q.where('username', Q.like(
`%${Q.sanitizeLikeString(likeUsername)}%`,
`%${sanitizeLikeString(likeUsername)}%`,
)),
);
};

View File

@@ -14,7 +14,7 @@ import {Screens} from '@constants';
import {ACCESSORIES_CONTAINER_NATIVE_ID} from '@constants/post_draft';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useChannelSwitch} from '@hooks/channel_switch';
import {useAppState, useIsTablet} from '@hooks/device';
import {useIsTablet} from '@hooks/device';
import {useDefaultHeaderHeight} from '@hooks/header';
import {useKeyboardTrackingPaused} from '@hooks/keyboard_tracking';
import {useTeamSwitch} from '@hooks/team_switch';
@@ -35,6 +35,7 @@ type ChannelProps = {
isInACall: boolean;
isInCurrentChannelCall: boolean;
isCallsEnabledInChannel: boolean;
isTabletView?: boolean;
};
const edges: Edge[] = ['left', 'right'];
@@ -54,8 +55,8 @@ const Channel = ({
isInACall,
isInCurrentChannelCall,
isCallsEnabledInChannel,
isTabletView,
}: ChannelProps) => {
const appState = useAppState();
const isTablet = useIsTablet();
const insets = useSafeAreaInsets();
const [shouldRenderPosts, setShouldRenderPosts] = useState(false);
@@ -125,13 +126,13 @@ const Channel = ({
channelId={channelId}
componentId={componentId}
callsEnabledInChannel={isCallsEnabledInChannel}
isTabletView={isTabletView}
/>
{shouldRender &&
<>
<View style={[styles.flex, {marginTop}]}>
<ChannelPostList
channelId={channelId}
forceQueryAfterAppState={appState}
nativeID={channelId}
currentCallBarVisible={isInACall}
joinCallBannerVisible={showJoinCallBanner}

View File

@@ -1,16 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useRef} from 'react';
import React, {useCallback, useEffect, useRef} from 'react';
import {StyleProp, StyleSheet, ViewStyle} from 'react-native';
import {Edge, SafeAreaView} from 'react-native-safe-area-context';
import {markChannelAsRead} from '@actions/remote/channel';
import {fetchPostsBefore} from '@actions/remote/post';
import PostList from '@components/post_list';
import {Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {debounce} from '@helpers/api/general';
import {useIsTablet} from '@hooks/device';
import {useAppState, useIsTablet} from '@hooks/device';
import Intro from './intro';
@@ -39,11 +40,20 @@ const ChannelPostList = ({
lastViewedAt, nativeID, posts, shouldShowJoinLeaveMessages,
currentCallBarVisible, joinCallBannerVisible,
}: Props) => {
const appState = useAppState();
const isTablet = useIsTablet();
const serverUrl = useServerUrl();
const canLoadPosts = useRef(true);
const fetchingPosts = useRef(false);
const oldPostsCount = useRef<number>(posts.length);
useEffect(() => {
if (oldPostsCount.current < posts.length && appState === 'active') {
oldPostsCount.current = posts.length;
markChannelAsRead(serverUrl, channelId, true);
}
}, [isCRTEnabled, posts, channelId, serverUrl, appState === 'active']);
const onEndReached = useCallback(debounce(async () => {
if (!fetchingPosts.current && canLoadPosts.current && posts.length) {
fetchingPosts.current = true;

View File

@@ -18,9 +18,8 @@ import {observeIsCRTEnabled} from '@queries/servers/thread';
import ChannelPostList from './channel_post_list';
import type {WithDatabaseArgs} from '@typings/database/database';
import type {AppStateStatus} from 'react-native';
const enhanced = withObservables(['channelId', 'forceQueryAfterAppState'], ({database, channelId}: {channelId: string; forceQueryAfterAppState: AppStateStatus} & WithDatabaseArgs) => {
const enhanced = withObservables(['channelId'], ({database, channelId}: {channelId: string} & WithDatabaseArgs) => {
const isCRTEnabledObserver = observeIsCRTEnabled(database);
const postsInChannelObserver = queryPostsInChannel(database, channelId).observeWithColumns(['earliest', 'latest']);

View File

@@ -42,6 +42,7 @@ type ChannelProps = {
searchTerm: string;
teamId: string;
callsEnabledInChannel: boolean;
isTabletView?: boolean;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
@@ -69,7 +70,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
const ChannelHeader = ({
channelId, channelType, componentId, customStatus, displayName,
isCustomStatusEnabled, isCustomStatusExpired, isOwnDirectMessage, memberCount,
searchTerm, teamId, callsEnabledInChannel,
searchTerm, teamId, callsEnabledInChannel, isTabletView,
}: ChannelProps) => {
const intl = useIntl();
const isTablet = useIsTablet();
@@ -233,7 +234,7 @@ const ChannelHeader = ({
onBackPress={onBackPress}
onTitlePress={onTitlePress}
rightButtons={rightButtons}
showBackButton={!isTablet}
showBackButton={!isTablet || !isTabletView}
subtitle={subtitle}
subtitleCompanion={subtitleCompanion}
title={title}

View File

@@ -3,6 +3,7 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React from 'react';
import {of as of$} from 'rxjs';
import {combineLatestWith, switchMap} from 'rxjs/operators';
@@ -91,4 +92,4 @@ const enhanced = withObservables(['channelId'], ({channelId, database}: OwnProps
};
});
export default withDatabase(enhanced(ChannelHeader));
export default withDatabase(enhanced(React.memo(ChannelHeader)));

View File

@@ -136,9 +136,8 @@ const ForgotPassword = ({componentId, serverUrl, theme}: Props) => {
return;
}
const {data} = await sendPasswordResetEmail(serverUrl, email);
if (data) {
const {status} = await sendPasswordResetEmail(serverUrl, email);
if (status === 'OK') {
setIsPasswordLinkSent(true);
return;
}

View File

@@ -11,7 +11,7 @@ import NavigationHeader from '@components/navigation_header';
import RoundedHeaderContext from '@components/rounded_header_context';
import {useServerUrl} from '@context/server';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useAppState, useIsTablet} from '@hooks/device';
import {useIsTablet} from '@hooks/device';
import {useDefaultHeaderHeight} from '@hooks/header';
import {useTeamSwitch} from '@hooks/team_switch';
import {popTopScreen} from '@screens/navigation';
@@ -34,7 +34,6 @@ const styles = StyleSheet.create({
});
const GlobalThreads = ({componentId, globalThreadsTab}: Props) => {
const appState = useAppState();
const serverUrl = useServerUrl();
const intl = useIntl();
const switchingTeam = useTeamSwitch();
@@ -93,7 +92,6 @@ const GlobalThreads = ({componentId, globalThreadsTab}: Props) => {
{!switchingTeam &&
<View style={containerStyle}>
<ThreadsList
forceQueryAfterAppState={appState}
setTab={setTab}
tab={tab}
testID={'global_threads.threads_list'}

View File

@@ -12,19 +12,17 @@ import {observeTeammateNameDisplay} from '@queries/servers/user';
import ThreadsList from './threads_list';
import type {WithDatabaseArgs} from '@typings/database/database';
import type {AppStateStatus} from 'react-native';
type Props = {
tab: GlobalThreadsTab;
teamId: string;
forceQueryAfterAppState: AppStateStatus;
} & WithDatabaseArgs;
const withTeamId = withObservables([], ({database}: WithDatabaseArgs) => ({
teamId: observeCurrentTeamId(database),
}));
const enhanced = withObservables(['tab', 'teamId', 'forceQueryAfterAppState'], ({database, tab, teamId}: Props) => {
const enhanced = withObservables(['tab', 'teamId'], ({database, tab, teamId}: Props) => {
const getOnlyUnreads = tab !== 'all';
const teamThreadsSyncObserver = queryTeamThreadsSync(database, teamId).observeWithColumns(['earliest']);

View File

@@ -205,7 +205,7 @@ const Thread = ({author, channel, location, post, teammateNameDisplay, testID, t
enableSoftBreak={true}
textStyle={textStyles}
baseStyle={styles.message}
value={post.message}
value={post.message.substring(0, 100)} // This substring helps to avoid ANR's
/>
</Text>
);

View File

@@ -57,7 +57,7 @@ const AdditionalTabletView = ({onTeam, currentChannelId, isCRTEnabled}: Props) =
return null;
}
return React.createElement(selected.Component, {componentId: selected.id, isTablet: true});
return React.createElement(selected.Component, {componentId: selected.id, isTabletView: true});
};
export default AdditionalTabletView;

View File

@@ -63,7 +63,7 @@ const CategoryBody = ({sortedChannels, unreadIds, unreadsOnTop, category, limit,
useEffect(() => {
if (directChannels.length) {
fetchDirectChannelsInfo(serverUrl, directChannels);
fetchDirectChannelsInfo(serverUrl, directChannels.filter((c) => !c.displayName));
}
}, [directChannels.length]);

View File

@@ -11,7 +11,7 @@ import {General, Preferences} from '@constants';
import {DMS_CATEGORY} from '@constants/categories';
import {getSidebarPreferenceAsBool} from '@helpers/api/preference';
import {observeChannelsByCategoryChannelSortOrder, observeChannelsByLastPostAtInCategory} from '@queries/servers/categories';
import {observeNotifyPropsByChannels, queryChannelsByNames, queryEmptyDirectAndGroupChannels} from '@queries/servers/channel';
import {observeArchivedDirectChannels, observeNotifyPropsByChannels, queryChannelsByNames, queryEmptyDirectAndGroupChannels} from '@queries/servers/channel';
import {queryPreferencesByCategoryAndName, querySidebarPreferences} from '@queries/servers/preference';
import {observeCurrentChannelId, observeCurrentUserId, observeLastUnreadChannelId} from '@queries/servers/system';
import {getDirectChannelName} from '@utils/channel';
@@ -112,19 +112,8 @@ const enhance = withObservables(['category', 'isTablet', 'locale'], ({category,
switchMap(mapChannelIds),
);
const hiddenChannelIds = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.GROUP_CHANNEL_SHOW, undefined, 'false').
observeWithColumns(['value']).pipe(
switchMap(mapPrefName),
combineLatestWith(hiddenDmIds, emptyDmIds),
switchMap(([hIds, hDmIds, eDmIds]) => {
return of$(new Set(hIds.concat(hDmIds, eDmIds)));
}),
);
const sortedChannels = hiddenChannelIds.pipe(
switchMap((excludeIds) => observeSortedChannels(database, category, Array.from(excludeIds), locale)),
combineLatestWith(currentChannelId),
map(([channels, ccId]) => filterArchived(channels, ccId)),
const archivedDmIds = observeArchivedDirectChannels(database, currentUserId).pipe(
switchMap(mapChannelIds),
);
let limit = of$(Preferences.CHANNEL_SIDEBAR_LIMIT_DMS_DEFAULT);
@@ -144,6 +133,25 @@ const enhance = withObservables(['category', 'isTablet', 'locale'], ({category,
);
const lastUnreadId = isTablet ? observeLastUnreadChannelId(database) : of$(undefined);
const hiddenChannelIds = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.GROUP_CHANNEL_SHOW, undefined, 'false').
observeWithColumns(['value']).pipe(
switchMap(mapPrefName),
combineLatestWith(hiddenDmIds, emptyDmIds, archivedDmIds, lastUnreadId),
switchMap(([hIds, hDmIds, eDmIds, aDmIds, excludeId]) => {
const hidden = new Set(hIds.concat(hDmIds, eDmIds, aDmIds));
if (excludeId) {
hidden.delete(excludeId);
}
return of$(hidden);
}),
);
const sortedChannels = hiddenChannelIds.pipe(
switchMap((excludeIds) => observeSortedChannels(database, category, Array.from(excludeIds), locale)),
combineLatestWith(currentChannelId),
map(([channels, ccId]) => filterArchived(channels, ccId)),
);
const unreadChannels = category.myChannels.observeWithColumns(['mentions_count', 'is_unread']);
const notifyProps = unreadChannels.pipe(switchMap((myChannels) => observeNotifyPropsByChannels(database, myChannels)));
const unreadIds = unreadChannels.pipe(

View File

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

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect} from 'react';
import React, {useEffect, useMemo} from 'react';
import {useWindowDimensions} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
@@ -60,19 +60,19 @@ const CategoriesList = ({channelsCount, iconPad, isCRTEnabled, teamsCount}: Chan
return {maxWidth: withTiming(tabletWidth.value, {duration: 350})};
}, [isTablet, width]);
let content;
const content = useMemo(() => {
if (channelsCount < 1) {
return (<LoadChannelsError/>);
}
if (channelsCount < 1) {
content = (<LoadChannelsError/>);
} else {
content = (
return (
<>
<SubHeader/>
{isCRTEnabled && <ThreadsButton/>}
<Categories/>
</>
);
}
}, [isCRTEnabled]);
return (
<Animated.View style={[styles.container, tabletStyle]}>

View File

@@ -11,7 +11,6 @@ import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-cont
import AnnouncementBanner from '@components/announcement_banner';
import ConnectionBanner from '@components/connection_banner';
import FreezeScreen from '@components/freeze_screen';
import TeamSidebar from '@components/team_sidebar';
import {Navigation as NavigationConstants, Screens} from '@constants';
import {useServerUrl} from '@context/server';
@@ -160,7 +159,7 @@ const ChannelListScreen = (props: ChannelProps) => {
}, []);
return (
<FreezeScreen freeze={!isFocused}>
<>
<Animated.View style={top}/>
<SafeAreaView
style={styles.flex}
@@ -192,7 +191,7 @@ const ChannelListScreen = (props: ChannelProps) => {
</Animated.View>
</View>
</SafeAreaView>
</FreezeScreen>
</>
);
};

View File

@@ -2,9 +2,9 @@
// See LICENSE.txt for license information.
import {useManagedConfig} from '@mattermost/react-native-emm';
import React, {MutableRefObject, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, Platform, TextInput, TouchableOpacity, useWindowDimensions, View} from 'react-native';
import {Keyboard, TextInput, TouchableOpacity, View} from 'react-native';
import Button from 'react-native-button';
import {login} from '@actions/remote/session';
@@ -14,7 +14,6 @@ import FloatingTextInput from '@components/floating_text_input_label';
import FormattedText from '@components/formatted_text';
import Loading from '@components/loading';
import {FORGOT_PASSWORD, MFA} from '@constants/screens';
import {useIsTablet} from '@hooks/device';
import {t} from '@i18n';
import {goToScreen, loginAnimationOptions, resetToHome, resetToTeams} from '@screens/navigation';
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
@@ -23,13 +22,10 @@ import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import type {LaunchProps} from '@typings/launch';
import type {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view';
interface LoginProps extends LaunchProps {
config: Partial<ClientConfig>;
keyboardAwareRef: MutableRefObject<KeyboardAwareScrollView | null>;
license: Partial<ClientLicense>;
numberSSOs: number;
serverDisplayName: string;
theme: Theme;
}
@@ -53,6 +49,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
},
forgotPasswordBtn: {
borderColor: 'transparent',
width: '50%',
},
forgotPasswordError: {
marginTop: 30,
@@ -76,10 +73,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
},
}));
const LoginForm = ({config, extra, keyboardAwareRef, numberSSOs, serverDisplayName, launchError, launchType, license, serverUrl, theme}: LoginProps) => {
const LoginForm = ({config, extra, serverDisplayName, launchError, launchType, license, serverUrl, theme}: LoginProps) => {
const styles = getStyleSheet(theme);
const isTablet = useIsTablet();
const dimensions = useWindowDimensions();
const loginRef = useRef<TextInput>(null);
const passwordRef = useRef<TextInput>(null);
const intl = useIntl();
@@ -94,33 +89,6 @@ const LoginForm = ({config, extra, keyboardAwareRef, numberSSOs, serverDisplayNa
const usernameEnabled = config.EnableSignInWithUsername === 'true';
const ldapEnabled = license.IsLicensed === 'true' && config.EnableLdap === 'true' && license.LDAP === 'true';
const focus = () => {
if (Platform.OS === 'ios') {
let ssoOffset = 0;
switch (numberSSOs) {
case 0:
ssoOffset = 0;
break;
case 1:
case 2:
ssoOffset = 48;
break;
default:
ssoOffset = 3 * 48;
break;
}
let offsetY = 150 - ssoOffset;
if (isTablet) {
const {width, height} = dimensions;
const isLandscape = width > height;
offsetY = (isLandscape ? 230 : 150) - ssoOffset;
}
requestAnimationFrame(() => {
keyboardAwareRef.current?.scrollToPosition(0, offsetY);
});
}
};
const preSignIn = preventDoubleTap(async () => {
setIsLoading(true);
@@ -231,19 +199,6 @@ const LoginForm = ({config, extra, keyboardAwareRef, numberSSOs, serverDisplayNa
passwordRef?.current?.focus();
}, []);
const onBlur = useCallback(() => {
if (Platform.OS === 'ios') {
const reset = !passwordRef.current?.isFocused() && !loginRef.current?.isFocused();
if (reset) {
keyboardAwareRef.current?.scrollToPosition(0, 0);
}
}
}, []);
const onFocus = useCallback(() => {
focus();
}, [dimensions]);
const onLogin = useCallback(() => {
Keyboard.dismiss();
preSignIn();
@@ -360,9 +315,7 @@ const LoginForm = ({config, extra, keyboardAwareRef, numberSSOs, serverDisplayNa
error={error ? ' ' : ''}
keyboardType='email-address'
label={createLoginPlaceholder()}
onBlur={onBlur}
onChangeText={onLoginChange}
onFocus={onFocus}
onSubmitEditing={focusPassword}
ref={loginRef}
returnKeyType='next'
@@ -382,9 +335,7 @@ const LoginForm = ({config, extra, keyboardAwareRef, numberSSOs, serverDisplayNa
error={error}
keyboardType='default'
label={intl.formatMessage({id: 'login.password', defaultMessage: 'Password'})}
onBlur={onBlur}
onChangeText={onPasswordChange}
onFocus={onFocus}
onSubmitEditing={onLogin}
ref={passwordRef}
returnKeyType='join'

View File

@@ -35,7 +35,7 @@ export interface LoginOptionsProps extends LaunchProps {
license: ClientLicense;
serverDisplayName: string;
serverUrl: string;
ssoOptions: Record<string, boolean>;
ssoOptions: SsoWithOptions;
theme: Theme;
}
@@ -85,7 +85,7 @@ const LoginOptions = ({
const isTablet = useIsTablet();
const translateX = useSharedValue(dimensions.width);
const numberSSOs = useMemo(() => {
return Object.values(ssoOptions).filter((v) => v).length;
return Object.values(ssoOptions).filter((v) => v.enabled).length;
}, [ssoOptions]);
const description = useMemo(() => {
if (hasLoginForm) {
@@ -211,7 +211,7 @@ const LoginOptions = ({
<KeyboardAwareScrollView
bounces={true}
contentContainerStyle={[styles.innerContainer, additionalContainerStyle]}
enableAutomaticScroll={Platform.OS === 'android'}
enableAutomaticScroll={true}
enableOnAndroid={false}
enableResetScrollToCoords={true}
extraScrollHeight={0}
@@ -228,11 +228,9 @@ const LoginOptions = ({
<Form
config={config}
extra={extra}
keyboardAwareRef={keyboardAwareRef}
license={license}
launchError={launchError}
launchType={launchType}
numberSSOs={numberSSOs}
theme={theme}
serverDisplayName={serverDisplayName}
serverUrl={serverUrl}

View File

@@ -2,19 +2,17 @@
// See LICENSE.txt for license information.
import React from 'react';
import {Image, ImageSourcePropType, View} from 'react-native';
import {useIntl} from 'react-intl';
import {Image, ImageSourcePropType, Text, View} from 'react-native';
import Button from 'react-native-button';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {Sso} from '@constants';
import {t} from '@i18n';
import {buttonBackgroundStyle} from '@utils/buttonStyles';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
type SsoInfo = {
defaultMessage: string;
id: string;
text: string;
imageSrc?: ImageSourcePropType;
compassIcon?: string;
};
@@ -22,40 +20,38 @@ type SsoInfo = {
type Props = {
goToSso: (ssoType: string) => void;
ssoOnly: boolean;
ssoOptions: Record<string, boolean>;
ssoOptions: SsoWithOptions;
theme: Theme;
}
const SsoOptions = ({goToSso, ssoOnly, ssoOptions, theme}: Props) => {
const {formatMessage} = useIntl();
const styles = getStyleSheet(theme);
const styleButtonBackground = buttonBackgroundStyle(theme, 'lg', 'primary');
const getSsoButtonOptions = ((ssoType: string): SsoInfo => {
const sso: SsoInfo = {} as SsoInfo;
const options = ssoOptions[ssoType];
switch (ssoType) {
case Sso.SAML:
sso.defaultMessage = 'SAML';
sso.text = options.text || formatMessage({id: 'mobile.login_options.saml', defaultMessage: 'SAML'});
sso.compassIcon = 'lock';
sso.id = t('mobile.login_options.saml');
break;
case Sso.GITLAB:
sso.defaultMessage = 'GitLab';
sso.text = formatMessage({id: 'mobile.login_options.gitlab', defaultMessage: 'GitLab'});
sso.imageSrc = require('@assets/images/Icon_Gitlab.png');
sso.id = t('mobile.login_options.gitlab');
break;
case Sso.GOOGLE:
sso.defaultMessage = 'Google';
sso.text = formatMessage({id: 'mobile.login_options.google', defaultMessage: 'Google'});
sso.imageSrc = require('@assets/images/Icon_Google.png');
sso.id = t('mobile.login_options.google');
break;
case Sso.OFFICE365:
sso.defaultMessage = 'Office 365';
sso.text = formatMessage({id: 'mobile.login_options.office365', defaultMessage: 'Office 365'});
sso.imageSrc = require('@assets/images/Icon_Office.png');
sso.id = t('mobile.login_options.office365');
break;
case Sso.OPENID:
sso.defaultMessage = 'Open ID';
sso.id = t('mobile.login_options.openid');
sso.text = options.text || formatMessage({id: 'mobile.login_options.openid', defaultMessage: 'Open ID'});
sso.imageSrc = require('@assets/images/Icon_Openid.png');
break;
default:
@@ -64,7 +60,7 @@ const SsoOptions = ({goToSso, ssoOnly, ssoOptions, theme}: Props) => {
});
const enabledSSOs = Object.keys(ssoOptions).filter(
(ssoType: string) => ssoOptions[ssoType],
(ssoType: string) => ssoOptions[ssoType].enabled,
);
let styleViewContainer;
@@ -76,7 +72,7 @@ const SsoOptions = ({goToSso, ssoOnly, ssoOptions, theme}: Props) => {
const componentArray = [];
for (const ssoType of enabledSSOs) {
const {compassIcon, defaultMessage, id, imageSrc} = getSsoButtonOptions(ssoType);
const {compassIcon, text, imageSrc} = getSsoButtonOptions(ssoType);
const handlePress = () => {
goToSso(ssoType);
};
@@ -105,21 +101,21 @@ const SsoOptions = ({goToSso, ssoOnly, ssoOptions, theme}: Props) => {
style={styles.buttonTextContainer}
>
{ssoOnly && (
<FormattedText
key={'pretext' + id}
id='mobile.login_options.sso_continue'
<Text
key={'pretext' + text}
style={styles.buttonText}
defaultMessage={'Continue with '}
testID={'pretext' + id}
/>
testID={'pretext' + text}
>
{text}
</Text>
)}
<FormattedText
<Text
key={ssoType}
id={id}
style={styles.buttonText}
defaultMessage={defaultMessage}
testID={id}
/>
testID={text}
>
{text}
</Text>
</View>
</Button>,
);

View File

@@ -79,7 +79,7 @@ const ServerHeader = ({additionalServer, theme}: Props) => {
}
{title}
<FormattedText
defaultMessage="A Server is your team's communication hub which is accessed through a unique URL"
defaultMessage="A server is your team's communication hub accessed using a unique URL"
id='mobile.components.select_server_view.msg_description'
style={styles.description}
testID='server_header.description'

View File

@@ -13,7 +13,6 @@ import RoundedHeaderContext from '@components/rounded_header_context';
import {Screens} from '@constants';
import {THREAD_ACCESSORIES_CONTAINER_NATIVE_ID} from '@constants/post_draft';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useAppState} from '@hooks/device';
import useDidUpdate from '@hooks/did_update';
import {useKeyboardTrackingPaused} from '@hooks/keyboard_tracking';
import {popTopScreen} from '@screens/navigation';
@@ -39,7 +38,6 @@ const styles = StyleSheet.create({
});
const Thread = ({componentId, rootPost, isInACall}: ThreadProps) => {
const appState = useAppState();
const postDraftRef = useRef<KeyboardTrackingViewRef>(null);
const [containerHeight, setContainerHeight] = useState(0);
const rootId = rootPost?.id || '';
@@ -81,7 +79,6 @@ const Thread = ({componentId, rootPost, isInACall}: ThreadProps) => {
<>
<View style={styles.flex}>
<ThreadPostList
forceQueryAfterAppState={appState}
nativeID={rootPost!.id}
rootPost={rootPost!}
/>

View File

@@ -15,14 +15,12 @@ import ThreadPostList from './thread_post_list';
import type {WithDatabaseArgs} from '@typings/database/database';
import type PostModel from '@typings/database/models/servers/post';
import type {AppStateStatus} from 'react-native';
type Props = WithDatabaseArgs & {
forceQueryAfterAppState: AppStateStatus;
rootPost: PostModel;
};
const enhanced = withObservables(['forceQueryAfterAppState', 'rootPost'], ({database, rootPost}: Props) => {
const enhanced = withObservables(['rootPost'], ({database, rootPost}: Props) => {
return {
isCRTEnabled: observeIsCRTEnabled(database),
channelLastViewedAt: observeMyChannel(database, rootPost.channelId).pipe(

View File

@@ -13,7 +13,7 @@ import {Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {debounce} from '@helpers/api/general';
import {useIsTablet} from '@hooks/device';
import {useAppState, useIsTablet} from '@hooks/device';
import {useFetchingThreadState} from '@hooks/fetching_thread';
import {isMinimumServerVersion} from '@utils/helpers';
@@ -43,6 +43,7 @@ const ThreadPostList = ({
channelLastViewedAt, isCRTEnabled,
nativeID, posts, rootPost, teamId, thread, version,
}: Props) => {
const appState = useAppState();
const isTablet = useIsTablet();
const serverUrl = useServerUrl();
const theme = useTheme();
@@ -82,11 +83,11 @@ const ThreadPostList = ({
// If CRT is enabled, When new post arrives and thread modal is open, mark thread as read.
const oldPostsCount = useRef<number>(posts.length);
useEffect(() => {
if (isCRTEnabled && thread?.isFollowing && oldPostsCount.current < posts.length) {
if (isCRTEnabled && thread?.isFollowing && oldPostsCount.current < posts.length && appState === 'active') {
oldPostsCount.current = posts.length;
markThreadAsRead(serverUrl, teamId, rootPost.id);
markThreadAsRead(serverUrl, teamId, rootPost.id, false);
}
}, [isCRTEnabled, posts, rootPost, serverUrl, teamId, thread]);
}, [isCRTEnabled, posts, rootPost, serverUrl, teamId, thread, appState === 'active']);
const lastViewedAt = isCRTEnabled ? (thread?.viewedAt ?? 0) : channelLastViewedAt;

View File

@@ -37,6 +37,8 @@ export const convertToNotificationData = (notification: Notification, tapped = t
type: payload.type,
use_user_icon: payload.use_user_icon,
version: payload.version,
isCRTEnabled: typeof payload.is_crt_enabled === 'string' ? payload.is_crt_enabled === 'true' : Boolean(payload.is_crt_enabled),
data: payload.data,
},
userInteraction: tapped,
foreground: false,

View File

@@ -75,12 +75,12 @@ export function loginOptions(config: ClientConfig, license: ClientLicense) {
}
const ldapEnabled = isLicensed && config.EnableLdap === 'true' && license.LDAP === 'true';
const hasLoginForm = config.EnableSignInWithEmail === 'true' || config.EnableSignInWithUsername === 'true' || ldapEnabled;
const ssoOptions: Record<string, boolean> = {
[Sso.SAML]: samlEnabled,
[Sso.GITLAB]: gitlabEnabled,
[Sso.GOOGLE]: googleEnabled,
[Sso.OFFICE365]: o365Enabled,
[Sso.OPENID]: openIdEnabled,
const ssoOptions: SsoWithOptions = {
[Sso.SAML]: {enabled: samlEnabled, text: config.SamlLoginButtonText},
[Sso.GITLAB]: {enabled: gitlabEnabled},
[Sso.GOOGLE]: {enabled: googleEnabled},
[Sso.OFFICE365]: {enabled: o365Enabled},
[Sso.OPENID]: {enabled: openIdEnabled, text: config.OpenIdButtonText},
};
const enabledSSOs = Object.keys(ssoOptions).filter((key) => ssoOptions[key]);
const numberSSOs = enabledSSOs.length;

View File

@@ -475,7 +475,7 @@
"mobile.components.select_server_view.displayName": "Display Name",
"mobile.components.select_server_view.enterServerUrl": "Enter Server URL",
"mobile.components.select_server_view.msg_connect": "Lets Connect to a Server",
"mobile.components.select_server_view.msg_description": "A Server is your team's communication hub which is accessed through a unique URL",
"mobile.components.select_server_view.msg_description": "A server is your team's communication hub accessed using a unique URL",
"mobile.components.select_server_view.msg_welcome": "Welcome",
"mobile.components.select_server_view.proceed": "Proceed",
"mobile.create_channel": "Create",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

2444
detox/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,25 +8,25 @@
"@babel/plugin-transform-modules-commonjs": "7.20.11",
"@babel/plugin-transform-runtime": "7.19.6",
"@babel/preset-env": "7.20.2",
"@jest/test-sequencer": "29.4.1",
"@jest/test-sequencer": "29.4.3",
"@types/jest": "29.4.0",
"@types/tough-cookie": "4.0.2",
"@types/uuid": "9.0.0",
"aws-sdk": "2.1305.0",
"axios": "1.3.0",
"aws-sdk": "2.1315.0",
"axios": "1.3.3",
"axios-cookiejar-support": "4.0.6",
"babel-jest": "29.4.1",
"babel-jest": "29.4.3",
"babel-plugin-module-resolver": "5.0.0",
"client-oauth2": "4.3.3",
"deepmerge": "4.3.0",
"detox": "20.1.2",
"detox": "20.1.3",
"form-data": "4.0.0",
"jest": "29.4.1",
"jest-circus": "29.4.1",
"jest-cli": "29.4.1",
"jest-html-reporters": "3.1.1",
"jest": "29.4.3",
"jest-circus": "29.4.3",
"jest-cli": "29.4.3",
"jest-html-reporters": "3.1.3",
"jest-junit": "15.0.0",
"jest-stare": "2.4.1",
"jest-stare": "2.5.0",
"junit-report-merger": "4.0.0",
"moment-timezone": "0.5.40",
"recursive-readdir": "2.2.3",

View File

@@ -0,0 +1,13 @@
import Foundation
extension ImageCache {
func image(for userId: String, updatedAt: Double, forServer serverUrl: String) -> Data? {
lock.lock(); defer { lock.unlock() }
let key = "\(serverUrl)-\(userId)-\(updatedAt)" as NSString
if let image = imageCache.object(forKey: key) as? Data {
return image
}
return nil
}
}

View File

@@ -0,0 +1,29 @@
import Foundation
extension ImageCache {
public func removeAllImages() {
imageCache.removeAllObjects()
keysCache.removeAllObjects()
}
func insertImage(_ data: Data?, for userId: String, updatedAt: Double, forServer serverUrl: String ) {
guard let data = data else {
return removeImage(for: userId, forServer: serverUrl)
}
lock.lock(); defer { lock.unlock() }
let cacheKey = "\(serverUrl)-\(userId)" as NSString
let imageKey = "\(cacheKey)-\(updatedAt)" as NSString
imageCache.setObject(NSData(data: data), forKey: imageKey as NSString, cost: data.count)
keysCache.setObject(imageKey, forKey: cacheKey)
}
func removeImage(for userId: String, forServer serverUrl: String) {
lock.lock(); defer { lock.unlock() }
let cacheKey = "\(serverUrl)-\(userId)" as NSString
if let key = keysCache.object(forKey: cacheKey) {
keysCache.removeObject(forKey: cacheKey)
imageCache.removeObject(forKey: key)
}
}
}

View File

@@ -0,0 +1,31 @@
import Foundation
public final class ImageCache: ImageCacheType {
public static let `default` = ImageCache()
struct Config {
let countLimit: Int
let memoryLimit: Int
static let defaultConfig = Config(countLimit: 50, memoryLimit: 1024 * 1024 * 50)
}
lazy var imageCache: NSCache<NSString, NSData> = {
let cache = NSCache<NSString, NSData>()
cache.countLimit = config.countLimit
cache.totalCostLimit = config.memoryLimit
return cache
}()
lazy var keysCache: NSCache<NSString, NSString> = {
let cache = NSCache<NSString, NSString>()
cache.countLimit = config.countLimit
return cache
}()
let lock = NSLock()
let config: Config
private init(config: Config = Config.defaultConfig) {
self.config = config
}
}

View File

@@ -0,0 +1,11 @@
import Foundation
protocol ImageCacheType: AnyObject {
func image(for userId: String, updatedAt: Double, forServer serverUrl: String) -> Data?
func insertImage(_ data: Data?, for userId: String, updatedAt: Double, forServer serverUrl: String )
func removeImage(for userId: String, forServer serverUrl: String)
func removeAllImages()
}

View File

@@ -0,0 +1,109 @@
import Foundation
public struct CategoriesWithOrder: Codable {
let order: [String]
let categories: [Category]
public enum CategoriesWithOrderKeys: String, CodingKey {
case order, categories
}
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CategoriesWithOrderKeys.self)
order = values.decodeIfPresent(forKey: .order, defaultValue: [String]())
categories = (try? values.decode([Category].self, forKey: .categories)) ?? [Category]()
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CategoriesWithOrderKeys.self)
try container.encode(self.order, forKey: .order)
try container.encode(self.categories, forKey: .categories)
}
}
public struct Category: Codable {
let id: String
let channelIds: [String]
let collapsed: Bool
let displayName: String
let muted: Bool
let sortOrder: Int
let sorting: String
let teamId: String
let type: String
let userId: String
public enum CategoryKeys: String, CodingKey {
case id, collapsed, muted, sorting, type
case channelIds = "channel_ids"
case displayName = "display_name"
case sortOrder = "sort_order"
case teamId = "team_id"
case userId = "user_id"
}
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CategoryKeys.self)
id = try values.decode(String.self, forKey: .id)
teamId = try values.decode(String.self, forKey: .teamId)
userId = try values.decode(String.self, forKey: .userId)
channelIds = values.decodeIfPresent(forKey: .channelIds, defaultValue: [String]())
collapsed = false
displayName = values.decodeIfPresent(forKey: .displayName, defaultValue: "")
muted = values.decodeIfPresent(forKey: .muted, defaultValue: false)
sortOrder = values.decodeIfPresent(forKey: .sortOrder, defaultValue: 0)
sorting = values.decodeIfPresent(forKey: .sorting, defaultValue: "recent")
type = values.decodeIfPresent(forKey: .type, defaultValue: "custom")
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CategoryKeys.self)
try container.encode(self.id, forKey: .id)
try container.encode(self.channelIds, forKey: .channelIds)
try container.encode(self.collapsed, forKey: .collapsed)
try container.encode(self.displayName, forKey: .displayName)
try container.encode(self.muted, forKey: .muted)
try container.encode(self.sortOrder, forKey: .sortOrder)
try container.encode(self.sorting, forKey: .sorting)
try container.encode(self.teamId, forKey: .teamId)
try container.encode(self.type, forKey: .type)
try container.encode(self.userId, forKey: .userId)
}
}
public struct CategoryChannel: Codable {
let id: String
let categoryId: String
let channelId: String
let sortOrder: Int
public enum CategoryChannelKeys: String, CodingKey {
case id
case channelId = "channel_id"
case categoryId = "category_id"
case sortOrder = "sort_order"
}
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CategoryChannelKeys.self)
id = try values.decode(String.self, forKey: .id)
channelId = try values.decode(String.self, forKey: .channelId)
categoryId = try values.decode(String.self, forKey: .categoryId)
sortOrder = values.decodeIfPresent(forKey: .sortOrder, defaultValue: 0)
}
public init(id: String, categoryId: String, channelId: String, sortOrder: Int = 0) {
self.id = id
self.categoryId = categoryId
self.channelId = channelId
self.sortOrder = sortOrder
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CategoryChannelKeys.self)
try container.encode(self.id, forKey: .id)
try container.encode(self.channelId, forKey: .channelId)
try container.encode(self.categoryId, forKey: .categoryId)
try container.encode(self.sortOrder, forKey: .sortOrder)
}
}

View File

@@ -0,0 +1,100 @@
import Foundation
public struct Channel: Codable {
let id: String
let createAt: Double
let creatorId: String
let deleteAt: Double
var displayName: String = ""
let extraUpdateAt: Double
let groupConstrained: Bool
let header: String
let lastPostAt: Double
let lastRootPostAt: Double
let name: String
let policyId: String
let props: String
let purpose: String
let schemeId: String
let shared: Bool
let teamId: String
let totalMsgCount: Int
let totalMsgCountRoot: Int
let type: String
let updateAt: Double
public enum ChannelKeys: String, CodingKey {
case id
case createAt = "create_at"
case creatorId = "creator_id"
case deleteAt = "delete_at"
case displayName = "display_name"
case extraUpdateAt = "extra_update_at"
case groupConstrained = "group_constrained"
case header
case lastPostAt = "last_post_at"
case lastRootPostAt = "last_root_post_at"
case name
case policyId = "policy_id"
case props
case purpose
case schemeId = "scheme_id"
case shared
case teamId = "team_id"
case totalMsgCount = "total_msg_count"
case totalMsgCountRoot = "total_msg_count_root"
case type
case updateAt = "update_at"
}
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: ChannelKeys.self)
id = try values.decode(String.self, forKey: .id)
creatorId = values.decodeIfPresent(forKey: .creatorId, defaultValue: "")
createAt = values.decodeIfPresent(forKey: .createAt, defaultValue: 0)
deleteAt = values.decodeIfPresent(forKey: .deleteAt, defaultValue: 0)
displayName = values.decodeIfPresent(forKey: .displayName, defaultValue: "")
extraUpdateAt = values.decodeIfPresent(forKey: .extraUpdateAt, defaultValue: 0)
groupConstrained = values.decodeIfPresent(forKey: .groupConstrained, defaultValue: false)
header = values.decodeIfPresent(forKey: .header, defaultValue: "")
lastPostAt = values.decodeIfPresent(forKey: .lastPostAt, defaultValue: 0)
lastRootPostAt = values.decodeIfPresent(forKey: .lastRootPostAt, defaultValue: 0)
name = values.decodeIfPresent(forKey: .name, defaultValue: "")
policyId = values.decodeIfPresent(forKey: .policyId, defaultValue: "")
let propsData = try? values.decode([String:Any].self, forKey: .props)
props = Database.default.json(from: propsData) ?? "{}"
purpose = values.decodeIfPresent(forKey: .purpose, defaultValue: "")
schemeId = values.decodeIfPresent(forKey: .schemeId, defaultValue: "")
shared = values.decodeIfPresent(forKey: .shared, defaultValue: false)
teamId = values.decodeIfPresent(forKey: .teamId, defaultValue: "")
totalMsgCount = values.decodeIfPresent(forKey: .totalMsgCount, defaultValue: 0)
totalMsgCountRoot = values.decodeIfPresent(forKey: .totalMsgCountRoot, defaultValue: 0)
type = values.decodeIfPresent(forKey: .type, defaultValue: "O")
updateAt = values.decodeIfPresent(forKey: .updateAt, defaultValue: 0)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: ChannelKeys.self)
try container.encode(self.id, forKey: .id)
try container.encode(self.creatorId, forKey: .creatorId)
try container.encode(self.createAt, forKey: .createAt)
try container.encode(self.deleteAt, forKey: .deleteAt)
try container.encode(self.displayName, forKey: .displayName)
try container.encode(self.extraUpdateAt, forKey: .extraUpdateAt)
try container.encode(self.groupConstrained, forKey: .groupConstrained)
try container.encode(self.header, forKey: .header)
try container.encode(self.lastPostAt, forKey: .lastPostAt)
try container.encode(self.lastRootPostAt, forKey: .lastRootPostAt)
try container.encode(self.name, forKey: .name)
try container.encode(self.policyId, forKey: .policyId)
try container.encode(self.props, forKey: .props)
try container.encode(self.purpose, forKey: .purpose)
try container.encode(self.schemeId, forKey: .schemeId)
try container.encode(self.shared, forKey: .shared)
try container.encode(self.teamId, forKey: .teamId)
try container.encode(self.totalMsgCount, forKey: .totalMsgCount)
try container.encode(self.totalMsgCountRoot, forKey: .totalMsgCountRoot)
try container.encode(self.type, forKey: .type)
try container.encode(self.updateAt, forKey: .updateAt)
}
}

View File

@@ -0,0 +1,85 @@
import Foundation
public struct ChannelMember: Codable {
let id: String
let explicitRoles: String
let lastUpdateAt: Double
let lastViewedAt: Double
let mentionCount: Int
let mentionCountRoot: Int
let msgCount: Int
let msgCountRoot: Int
let notifyProps: String
let roles: String
let schemeAdmin: Bool
let schemeGuest: Bool
let schemeUser: Bool
let urgentMentionCount: Int
let userId: String
var internalMsgCount: Int
var internalMsgCountRoot: Int
public enum ChannelMemberKeys: String, CodingKey {
case internalMsgCount, internalMsgCountRoot
case id = "channel_id"
case explicitRoles = "explicit_roles"
case lastUpdateAt = "last_update_at"
case lastViewedAt = "last_viewed_at"
case mentionCount = "mention_count"
case mentionCountRoot = "mention_count_root"
case msgCount = "msg_count"
case msgCountRoot = "msg_count_root"
case notifyProps = "notify_props"
case roles
case schemeAdmin = "scheme_admin"
case schemeGuest = "scheme_guest"
case schemeUser = "scheme_user"
case urgentMentionCount = "urgent_mention_count"
case userId = "user_id"
}
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: ChannelMemberKeys.self)
id = try values.decode(String.self, forKey: .id)
userId = try values.decode(String.self, forKey: .userId)
explicitRoles = values.decodeIfPresent(forKey: .explicitRoles, defaultValue: "")
lastUpdateAt = values.decodeIfPresent(forKey: .lastUpdateAt, defaultValue: 0)
lastViewedAt = values.decodeIfPresent(forKey: .lastViewedAt, defaultValue: 0)
mentionCount = values.decodeIfPresent(forKey: .mentionCount, defaultValue: 0)
mentionCountRoot = values.decodeIfPresent(forKey: .mentionCountRoot, defaultValue: 0)
msgCount = values.decodeIfPresent(forKey: .msgCount, defaultValue: 0)
msgCountRoot = values.decodeIfPresent(forKey: .msgCountRoot, defaultValue: 0)
let propsData = try values.decode([String:Any].self, forKey: .notifyProps)
notifyProps = Database.default.json(from: propsData) ?? "{}"
roles = values.decodeIfPresent(forKey: .roles, defaultValue: "")
schemeAdmin = values.decodeIfPresent(forKey: .schemeAdmin, defaultValue: false)
schemeGuest = values.decodeIfPresent(forKey: .schemeGuest, defaultValue: false)
schemeUser = values.decodeIfPresent(forKey: .schemeUser, defaultValue: true)
urgentMentionCount = values.decodeIfPresent(forKey: .urgentMentionCount, defaultValue: 0)
internalMsgCount = 0
internalMsgCountRoot = 0
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: ChannelMemberKeys.self)
try container.encode(self.id, forKey: .id)
try container.encode(self.explicitRoles, forKey: .explicitRoles)
try container.encode(self.lastUpdateAt, forKey: .lastUpdateAt)
try container.encode(self.lastViewedAt, forKey: .lastViewedAt)
try container.encode(self.mentionCount, forKey: .mentionCount)
try container.encode(self.mentionCountRoot, forKey: .mentionCountRoot)
try container.encode(self.msgCount, forKey: .msgCount)
try container.encode(self.msgCountRoot, forKey: .msgCountRoot)
try container.encode(self.notifyProps, forKey: .notifyProps)
try container.encode(self.roles, forKey: .roles)
try container.encode(self.schemeAdmin, forKey: .schemeAdmin)
try container.encode(self.schemeGuest, forKey: .schemeGuest)
try container.encode(self.schemeUser, forKey: .schemeUser)
try container.encode(self.urgentMentionCount, forKey: .urgentMentionCount)
try container.encode(self.userId, forKey: .userId)
try container.encode(self.internalMsgCount, forKey: .internalMsgCount)
try container.encode(self.internalMsgCountRoot, forKey: .internalMsgCountRoot)
}
}

View File

@@ -0,0 +1,103 @@
import Foundation
public struct Post: Codable {
let id: String
let createAt: Double
let updateAt: Double
let editAt: Double
let deleteAt: Double
let isPinned: Bool
let userId: String
let channelId: String
let rootId: String
let originalId: String
let message: String
let type: String
let props: String
let pendingPostId: String
let metadata: String
var prevPostId: String
// CRT
let participants: [User]?
let lastReplyAt: Double
let replyCount: Int
let isFollowing: Bool
public enum PostKeys: String, CodingKey {
case id, message, type, props, metadata, participants
case createAt = "create_at"
case updateAt = "update_at"
case deleteAt = "delete_at"
case editAt = "edit_at"
case isPinned = "is_pinned"
case userId = "user_id"
case channelId = "channel_id"
case rootId = "root_id"
case originalId = "original_id"
case pendingPostId = "pending_post_id"
case prevPostId = "previous_post_id"
// CRT
case lastReplyAt = "last_reply_at"
case replyCount = "reply_count"
case isFollowing = "is_following"
}
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: PostKeys.self)
prevPostId = ""
id = try values.decode(String.self, forKey: .id)
channelId = try values.decode(String.self, forKey: .channelId)
userId = try values.decode(String.self, forKey: .userId)
createAt = values.decodeIfPresent(forKey: .createAt, defaultValue: 0)
updateAt = values.decodeIfPresent(forKey: .updateAt, defaultValue: 0)
deleteAt = values.decodeIfPresent(forKey: .deleteAt, defaultValue: 0)
editAt = values.decodeIfPresent(forKey: .editAt, defaultValue: 0)
isPinned = values.decodeIfPresent(forKey: .isPinned, defaultValue: false)
rootId = values.decodeIfPresent(forKey: .rootId, defaultValue: "")
originalId = values.decodeIfPresent(forKey: .originalId, defaultValue: "")
message = values.decodeIfPresent(forKey: .message, defaultValue: "")
type = values.decodeIfPresent(forKey: .type, defaultValue: "")
pendingPostId = values.decodeIfPresent(forKey: .pendingPostId, defaultValue: "")
lastReplyAt = values.decodeIfPresent(forKey: .lastReplyAt, defaultValue: 0)
replyCount = values.decodeIfPresent(forKey: .replyCount, defaultValue: 0)
isFollowing = values.decodeIfPresent(forKey: .isFollowing, defaultValue: false)
participants = (try? values.decodeIfPresent([User].self, forKey: .participants)) ?? nil
if let meta = try? values.decode([String:Any].self, forKey: .metadata) {
metadata = Database.default.json(from: meta) ?? "{}"
} else {
metadata = "{}"
}
if let propsData = try? values.decode([String:Any].self, forKey: .props) {
props = Database.default.json(from: propsData) ?? "{}"
} else {
props = "{}"
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: PostKeys.self)
try container.encode(self.id, forKey: .id)
try container.encode(self.createAt, forKey: .createAt)
try container.encode(self.updateAt, forKey: .updateAt)
try container.encode(self.editAt, forKey: .editAt)
try container.encode(self.deleteAt, forKey: .deleteAt)
try container.encode(self.isPinned, forKey: .isPinned)
try container.encode(self.userId, forKey: .userId)
try container.encode(self.channelId, forKey: .channelId)
try container.encode(self.rootId, forKey: .rootId)
try container.encode(self.originalId, forKey: .originalId)
try container.encode(self.message, forKey: .message)
try container.encode(self.type, forKey: .type)
try container.encode(self.props, forKey: .props)
try container.encode(self.pendingPostId, forKey: .pendingPostId)
try container.encode(self.metadata, forKey: .metadata)
try container.encode(self.prevPostId, forKey: .prevPostId)
try container.encodeIfPresent(self.participants, forKey: .participants)
try container.encode(self.lastReplyAt, forKey: .lastReplyAt)
try container.encode(self.replyCount, forKey: .replyCount)
try container.encode(self.isFollowing, forKey: .isFollowing)
}
}

View File

@@ -0,0 +1,30 @@
import Foundation
public struct PostResponse: Codable {
let order: [String]
let posts: [String:Post]
let nextPostId: String
let prevPostId: String
public enum PostResponseKeys: String, CodingKey {
case order, posts
case nextPostId = "next_post_id"
case prevPostId = "prev_post_id"
}
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: PostResponseKeys.self)
order = values.decodeIfPresent(forKey: .order, defaultValue: [String]())
nextPostId = values.decodeIfPresent(forKey: .nextPostId, defaultValue: "")
prevPostId = values.decodeIfPresent(forKey: .prevPostId, defaultValue: "")
posts = (try? values.decode([String:Post].self, forKey: .posts)) ?? [String:Post]()
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: PostResponseKeys.self)
try container.encode(self.order, forKey: .order)
try container.encode(self.posts, forKey: .posts)
try container.encode(self.nextPostId, forKey: .nextPostId)
try container.encode(self.prevPostId, forKey: .prevPostId)
}
}

View File

@@ -0,0 +1,66 @@
import Foundation
public struct PostThread: Codable {
let id: String
var lastReplyAt: Double
var lastViewedAt: Double
let replyCount: Int
var unreadReplies: Int
var unreadMentions: Int
let post: Post?
let participants: [User]
let isFollowing: Bool
let deleteAt: Double
public enum PostThreadKeys: String, CodingKey {
case id, post, participants
case lastReplyAt = "last_reply_at"
case lastViewedAt = "last_viewed_at"
case replyCount = "reply_count"
case unreadReplies = "unread_replies"
case unreadMentions = "unread_mentions"
case isFollowing = "is_following"
case deleteAt = "delete_at"
}
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: PostThreadKeys.self)
id = try values.decode(String.self, forKey: .id)
post = values.decodeIfPresent(forKey: .post, defaultValue: nil)
participants = values.decodeIfPresent(forKey: .participants, defaultValue: [User]())
lastReplyAt = values.decodeIfPresent(forKey: .lastReplyAt, defaultValue: 0)
lastViewedAt = values.decodeIfPresent(forKey: .lastViewedAt, defaultValue: 0)
replyCount = values.decodeIfPresent(forKey: .replyCount, defaultValue: 0)
unreadReplies = values.decodeIfPresent(forKey: .unreadReplies, defaultValue: 0)
unreadMentions = values.decodeIfPresent(forKey: .unreadMentions, defaultValue: 0)
isFollowing = values.decodeIfPresent(forKey: .isFollowing, defaultValue: false)
deleteAt = values.decodeIfPresent(forKey: .deleteAt, defaultValue: 0)
}
public init(from post: Post) {
id = post.id
replyCount = post.replyCount
participants = post.participants ?? [User]()
isFollowing = post.isFollowing
deleteAt = post.deleteAt
lastReplyAt = 0
lastViewedAt = 0
unreadReplies = 0
unreadMentions = 0
self.post = post
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: PostThreadKeys.self)
try container.encode(self.id, forKey: .id)
try container.encode(self.lastReplyAt, forKey: .lastReplyAt)
try container.encode(self.lastViewedAt, forKey: .lastViewedAt)
try container.encode(self.replyCount, forKey: .replyCount)
try container.encode(self.unreadReplies, forKey: .unreadReplies)
try container.encode(self.unreadMentions, forKey: .unreadMentions)
try container.encodeIfPresent(self.post, forKey: .post)
try container.encode(self.participants, forKey: .participants)
try container.encode(self.isFollowing, forKey: .isFollowing)
try container.encode(self.deleteAt, forKey: .deleteAt)
}
}

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